aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-harness
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2022-12-23 12:59:29 +0100
committerFlorian Dold <florian@dold.me>2022-12-23 13:19:41 +0100
commit083c4cf5d96314c44dd716cf3cc931e95b651bbd (patch)
tree7f15a46224d5dfe495e26dc6ec66996c889498ff /packages/taler-harness
parentd98711cb51d13bb2da3682014c7c6e75d7fbb4f0 (diff)
downloadwallet-core-083c4cf5d96314c44dd716cf3cc931e95b651bbd.tar.xz
spill extra functionality from wallet-cli into taler-harness
We want to keep taler-wallet-cli smaller and have fewer dependencies.
Diffstat (limited to 'packages/taler-harness')
-rw-r--r--packages/taler-harness/Makefile30
-rwxr-xr-xpackages/taler-harness/bin/taler-harness.mjs19
-rwxr-xr-xpackages/taler-harness/build.mjs70
-rw-r--r--packages/taler-harness/package.json44
-rw-r--r--packages/taler-harness/src/bench1.ts189
-rw-r--r--packages/taler-harness/src/bench2.ts170
-rw-r--r--packages/taler-harness/src/bench3.ts205
-rw-r--r--packages/taler-harness/src/benchMerchantIDGenerator.ts83
-rw-r--r--packages/taler-harness/src/env-full.ts101
-rw-r--r--packages/taler-harness/src/env1.ts70
-rw-r--r--packages/taler-harness/src/harness/denomStructures.ts157
-rw-r--r--packages/taler-harness/src/harness/faultInjection.ts256
-rw-r--r--packages/taler-harness/src/harness/harness.ts2024
-rw-r--r--packages/taler-harness/src/harness/helpers.ts444
-rw-r--r--packages/taler-harness/src/harness/libeufin-apis.ts872
-rw-r--r--packages/taler-harness/src/harness/libeufin.ts910
-rw-r--r--packages/taler-harness/src/harness/merchantApiTypes.ts337
-rw-r--r--packages/taler-harness/src/harness/sync.ts119
-rw-r--r--packages/taler-harness/src/index.ts338
-rw-r--r--packages/taler-harness/src/integrationtests/scenario-prompt-payment.ts60
-rw-r--r--packages/taler-harness/src/integrationtests/test-age-restrictions-merchant.ts201
-rw-r--r--packages/taler-harness/src/integrationtests/test-age-restrictions-mixed-merchant.ts116
-rw-r--r--packages/taler-harness/src/integrationtests/test-age-restrictions-peer.ts92
-rw-r--r--packages/taler-harness/src/integrationtests/test-bank-api.ts136
-rw-r--r--packages/taler-harness/src/integrationtests/test-claim-loop.ts79
-rw-r--r--packages/taler-harness/src/integrationtests/test-clause-schnorr.ts97
-rw-r--r--packages/taler-harness/src/integrationtests/test-denom-unoffered.ts126
-rw-r--r--packages/taler-harness/src/integrationtests/test-deposit.ts71
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-management.ts285
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts240
-rw-r--r--packages/taler-harness/src/integrationtests/test-fee-regression.ts200
-rw-r--r--packages/taler-harness/src/integrationtests/test-forced-selection.ts87
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-api-bankaccount.ts109
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-api-bankconnection.ts56
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-api-facade-bad-request.ts71
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-api-facade.ts70
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-api-permissions.ts64
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-api-sandbox-camt.ts76
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-api-sandbox-transactions.ts69
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-api-scheduling.ts106
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-api-users.ts63
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-bad-gateway.ts74
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-basic.ts308
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-c5x.ts147
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-facade-anastasis.ts169
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-keyrotation.ts79
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-nexus-balance.ts118
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-refund-multiple-users.ts104
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-refund.ts101
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-sandbox-wire-transfer-cli.ts85
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-tutorial.ts128
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts243
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-instances-delete.ts129
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts189
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-instances.ts184
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-longpolling.ts162
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-refund-api.ts303
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts620
-rw-r--r--packages/taler-harness/src/integrationtests/test-pay-paid.ts222
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-claim.ts110
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-fault.ts222
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-forgettable.ts81
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-idempotency.ts121
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-multiple.ts163
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-on-demo.ts114
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-transient.ts185
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-zero.ts72
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment.ts77
-rw-r--r--packages/taler-harness/src/integrationtests/test-paywall-flow.ts252
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts101
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-to-peer-push.ts119
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund-auto.ts105
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund-gone.ts124
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund-incremental.ts202
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund.ts106
-rw-r--r--packages/taler-harness/src/integrationtests/test-revocation.ts215
-rw-r--r--packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts216
-rw-r--r--packages/taler-harness/src/integrationtests/test-timetravel-withdraw.ts98
-rw-r--r--packages/taler-harness/src/integrationtests/test-tipping.ts129
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-backup-basic.ts168
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-backup-doublespend.ts174
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-balance.ts144
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-cryptoworker.ts55
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-dbless.ts112
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallettesting.ts233
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-abort-bank.ts84
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts91
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts97
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-high.ts99
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts84
-rw-r--r--packages/taler-harness/src/integrationtests/testrunner.ts496
-rw-r--r--packages/taler-harness/src/lint.ts534
-rw-r--r--packages/taler-harness/tsconfig.json33
93 files changed, 17493 insertions, 0 deletions
diff --git a/packages/taler-harness/Makefile b/packages/taler-harness/Makefile
new file mode 100644
index 000000000..85c146641
--- /dev/null
+++ b/packages/taler-harness/Makefile
@@ -0,0 +1,30 @@
+# This Makefile has been placed in the public domain.
+
+-include ../../.config.mk
+include .config.mk
+
+$(info prefix is $(prefix))
+
+all:
+ @echo use 'make install' to build and install taler-harness
+
+ifndef prefix
+.PHONY: warn-noprefix install
+warn-noprefix:
+ @echo "no prefix configured, did you run ./configure?"
+install: warn-noprefix
+else
+install_target = $(prefix)/lib/taler-harness
+.PHONY: install install-nodeps
+install:
+ pnpm install --frozen-lockfile --filter @gnu-taler/taler-harness...
+ install -d $(prefix)/bin
+ install -d $(install_target)/bin
+ install -d $(install_target)/node_modules/taler-harness
+ install -d $(install_target)/node_modules/taler-harness/bin
+ install -d $(install_target)/node_modules/taler-harness/dist
+ install ./dist/taler-harness-bundled.cjs $(install_target)/node_modules/taler-harness/dist/
+ install ./dist/taler-harness-bundled.cjs.map $(install_target)/node_modules/taler-harness/dist/
+ install ./bin/taler-harness.mjs $(install_target)/node_modules/taler-harness/bin/
+ ln -sf $(install_target)/node_modules/taler-harness/bin/taler-harness.mjs $(prefix)/bin/taler-harness
+endif
diff --git a/packages/taler-harness/bin/taler-harness.mjs b/packages/taler-harness/bin/taler-harness.mjs
new file mode 100755
index 000000000..f8deebedb
--- /dev/null
+++ b/packages/taler-harness/bin/taler-harness.mjs
@@ -0,0 +1,19 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { main } from '../dist/taler-harness-bundled.cjs';
+main();
diff --git a/packages/taler-harness/build.mjs b/packages/taler-harness/build.mjs
new file mode 100755
index 000000000..b02d159e1
--- /dev/null
+++ b/packages/taler-harness/build.mjs
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import esbuild from 'esbuild'
+import path from "path"
+import fs from "fs"
+
+const BASE = process.cwd()
+
+let GIT_ROOT = BASE
+while (!fs.existsSync(path.join(GIT_ROOT, '.git')) && GIT_ROOT !== '/') {
+ GIT_ROOT = path.join(GIT_ROOT, '../')
+}
+if (GIT_ROOT === '/') {
+ console.log("not found")
+ process.exit(1);
+}
+const GIT_HASH = GIT_ROOT === '/' ? undefined : git_hash()
+
+
+let _package = JSON.parse(fs.readFileSync(path.join(BASE, 'package.json')));
+
+function git_hash() {
+ const rev = fs.readFileSync(path.join(GIT_ROOT, '.git', 'HEAD')).toString().trim().split(/.*[: ]/).slice(-1)[0];
+ if (rev.indexOf('/') === -1) {
+ return rev;
+ } else {
+ return fs.readFileSync(path.join(GIT_ROOT, '.git', rev)).toString().trim();
+ }
+}
+
+// Still commonjs, because axios doesn't work properly under mjs
+export const buildConfig = {
+ entryPoints: ["src/index.ts"],
+ outfile: "dist/taler-harness-bundled.cjs",
+ bundle: true,
+ minify: false,
+ target: [
+ 'es2020'
+ ],
+ format: 'cjs',
+ platform: 'node',
+ sourcemap: true,
+ define: {
+ '__VERSION__': `"${_package.version}"`,
+ '__GIT_HASH__': `"${GIT_HASH}"`,
+ },
+}
+
+esbuild
+ .build(buildConfig)
+ .catch((e) => {
+ console.log(e)
+ process.exit(1)
+ });
+
diff --git a/packages/taler-harness/package.json b/packages/taler-harness/package.json
new file mode 100644
index 000000000..f521924c4
--- /dev/null
+++ b/packages/taler-harness/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@gnu-taler/taler-harness",
+ "version": "0.9.0-dev.2",
+ "description": "",
+ "engines": {
+ "node": ">=0.12.0"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git://git.taler.net/wallet-core.git"
+ },
+ "author": "Florian Dold",
+ "license": "GPL-3.0",
+ "bin": {
+ "taler-harness": "./bin/taler-harness.mjs"
+ },
+ "type": "module",
+ "scripts": {
+ "compile": "./build.mjs",
+ "check": "tsc",
+ "clean": "rimraf lib dist tsconfig.tsbuildinfo",
+ "pretty": "prettier --write src"
+ },
+ "files": [
+ "AUTHORS",
+ "README",
+ "COPYING",
+ "bin/",
+ "dist/node",
+ "src/"
+ ],
+ "devDependencies": {
+ "@types/node": "^18.11.17",
+ "prettier": "^2.5.1",
+ "rimraf": "^3.0.2",
+ "typescript": "^4.8.4"
+ },
+ "dependencies": {
+ "@gnu-taler/taler-util": "workspace:*",
+ "@gnu-taler/taler-wallet-core": "workspace:*",
+ "axios": "^0.27.2",
+ "tslib": "^2.4.0"
+ }
+}
diff --git a/packages/taler-harness/src/bench1.ts b/packages/taler-harness/src/bench1.ts
new file mode 100644
index 000000000..84786d25a
--- /dev/null
+++ b/packages/taler-harness/src/bench1.ts
@@ -0,0 +1,189 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ buildCodecForObject,
+ codecForNumber,
+ codecForString,
+ codecForBoolean,
+ codecOptional,
+ j2s,
+ Logger,
+} from "@gnu-taler/taler-util";
+import {
+ getDefaultNodeWallet2,
+ NodeHttpLib,
+ WalletApiOperation,
+ Wallet,
+ AccessStats,
+} from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Entry point for the benchmark.
+ *
+ * The benchmark runs against an existing Taler deployment and does not
+ * set up its own services.
+ */
+export async function runBench1(configJson: any): Promise<void> {
+ const logger = new Logger("Bench1");
+
+ // Validate the configuration file for this benchmark.
+ const b1conf = codecForBench1Config().decode(configJson);
+
+ const myHttpLib = new NodeHttpLib();
+ myHttpLib.setThrottling(false);
+
+ const numIter = b1conf.iterations ?? 1;
+ const numDeposits = b1conf.deposits ?? 5;
+ const restartWallet = b1conf.restartAfter ?? 20;
+
+ const withdrawOnly = b1conf.withdrawOnly ?? false;
+ const withdrawAmount = (numDeposits + 1) * 10;
+
+ logger.info(
+ `Starting Benchmark iterations=${numIter} deposits=${numDeposits}`,
+ );
+
+ const trustExchange = !!process.env["TALER_WALLET_INSECURE_TRUST_EXCHANGE"];
+ if (trustExchange) {
+ logger.info("trusting exchange (not validating signatures)");
+ } else {
+ logger.info("not trusting exchange (validating signatures)");
+ }
+ const batchWithdrawal = !!process.env["TALER_WALLET_BATCH_WITHDRAWAL"];
+
+ let wallet = {} as Wallet;
+ let getDbStats: () => AccessStats;
+
+ for (let i = 0; i < numIter; i++) {
+ // Create a new wallet in each iteration
+ // otherwise the TPS go down
+ // my assumption is that the in-memory db file gets too large
+ if (i % restartWallet == 0) {
+ if (Object.keys(wallet).length !== 0) {
+ wallet.stop();
+ console.log("wallet DB stats", j2s(getDbStats!()));
+ }
+
+ const res = await getDefaultNodeWallet2({
+ // No persistent DB storage.
+ persistentStoragePath: undefined,
+ httpLib: myHttpLib,
+ });
+ wallet = res.wallet;
+ getDbStats = res.getDbStats;
+ if (trustExchange) {
+ wallet.setInsecureTrustExchange();
+ }
+ wallet.setBatchWithdrawal(batchWithdrawal);
+ await wallet.client.call(WalletApiOperation.InitWallet, {});
+ }
+
+ logger.trace(`Starting withdrawal amount=${withdrawAmount}`);
+ let start = Date.now();
+
+ await wallet.client.call(WalletApiOperation.WithdrawFakebank, {
+ amount: b1conf.currency + ":" + withdrawAmount,
+ bank: b1conf.bank,
+ exchange: b1conf.exchange,
+ });
+
+ await wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+
+ logger.info(
+ `Finished withdrawal amount=${withdrawAmount} time=${Date.now() - start}`,
+ );
+
+ if (!withdrawOnly) {
+ for (let i = 0; i < numDeposits; i++) {
+ logger.trace(`Starting deposit amount=10`);
+ start = Date.now();
+
+ await wallet.client.call(WalletApiOperation.CreateDepositGroup, {
+ amount: b1conf.currency + ":10",
+ depositPaytoUri: b1conf.payto,
+ });
+
+ await wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+
+ logger.info(`Finished deposit amount=10 time=${Date.now() - start}`);
+ }
+ }
+ }
+
+ wallet.stop();
+ console.log("wallet DB stats", j2s(getDbStats!()));
+}
+
+/**
+ * Format of the configuration file passed to the benchmark
+ */
+interface Bench1Config {
+ /**
+ * Base URL of the bank.
+ */
+ bank: string;
+
+ /**
+ * Payto url for deposits.
+ */
+ payto: string;
+
+ /**
+ * Base URL of the exchange.
+ */
+ exchange: string;
+
+ /**
+ * How many withdraw/deposit iterations should be made?
+ * Defaults to 1.
+ */
+ iterations?: number;
+
+ currency: string;
+
+ deposits?: number;
+
+ /**
+ * How any iterations run until the wallet db gets purged
+ * Defaults to 20.
+ */
+ restartAfter?: number;
+
+ withdrawOnly?: boolean;
+}
+
+/**
+ * Schema validation codec for Bench1Config.
+ */
+const codecForBench1Config = () =>
+ buildCodecForObject<Bench1Config>()
+ .property("bank", codecForString())
+ .property("payto", codecForString())
+ .property("exchange", codecForString())
+ .property("iterations", codecOptional(codecForNumber()))
+ .property("deposits", codecOptional(codecForNumber()))
+ .property("currency", codecForString())
+ .property("restartAfter", codecOptional(codecForNumber()))
+ .property("withdrawOnly", codecOptional(codecForBoolean()))
+ .build("Bench1Config");
diff --git a/packages/taler-harness/src/bench2.ts b/packages/taler-harness/src/bench2.ts
new file mode 100644
index 000000000..196737436
--- /dev/null
+++ b/packages/taler-harness/src/bench2.ts
@@ -0,0 +1,170 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ buildCodecForObject,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+ Logger,
+} from "@gnu-taler/taler-util";
+import {
+ checkReserve,
+ createFakebankReserve,
+ CryptoDispatcher,
+ depositCoin,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ NodeHttpLib,
+ refreshCoin,
+ SynchronousCryptoWorkerFactoryNode,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Entry point for the benchmark.
+ *
+ * The benchmark runs against an existing Taler deployment and does not
+ * set up its own services.
+ */
+export async function runBench2(configJson: any): Promise<void> {
+ const logger = new Logger("Bench2");
+
+ // Validate the configuration file for this benchmark.
+ const benchConf = codecForBench2Config().decode(configJson);
+ const curr = benchConf.currency;
+ const cryptoDisp = new CryptoDispatcher(new SynchronousCryptoWorkerFactoryNode());
+ const cryptoApi = cryptoDisp.cryptoApi;
+
+ const http = new NodeHttpLib();
+ http.setThrottling(false);
+
+ const numIter = benchConf.iterations ?? 1;
+ const numDeposits = benchConf.deposits ?? 5;
+
+ const reserveAmount = (numDeposits + 1) * 10;
+
+ for (let i = 0; i < numIter; i++) {
+ const exchangeInfo = await downloadExchangeInfo(benchConf.exchange, http);
+
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+
+ console.log("creating fakebank reserve");
+
+ await createFakebankReserve({
+ amount: `${curr}:${reserveAmount}`,
+ exchangeInfo,
+ fakebankBaseUrl: benchConf.bank,
+ http,
+ reservePub: reserveKeyPair.pub,
+ });
+
+ console.log("waiting for reserve");
+
+ await checkReserve(http, benchConf.exchange, reserveKeyPair.pub);
+
+ console.log("reserve found");
+
+ const d1 = findDenomOrThrow(exchangeInfo, `${curr}:8`);
+
+ for (let j = 0; j < numDeposits; j++) {
+ console.log("withdrawing coin");
+ const coin = await withdrawCoin({
+ http,
+ cryptoApi,
+ reserveKeyPair: {
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ },
+ denom: d1,
+ exchangeBaseUrl: benchConf.exchange,
+ });
+
+ console.log("depositing coin");
+
+ await depositCoin({
+ amount: `${curr}:4`,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: benchConf.exchange,
+ http,
+ depositPayto: benchConf.payto,
+ });
+
+ const refreshDenoms = [
+ findDenomOrThrow(exchangeInfo, `${curr}:1`),
+ findDenomOrThrow(exchangeInfo, `${curr}:1`),
+ ];
+
+ console.log("refreshing coin");
+
+ await refreshCoin({
+ oldCoin: coin,
+ cryptoApi,
+ http,
+ newDenoms: refreshDenoms,
+ });
+
+ console.log("refresh done");
+ }
+ }
+}
+
+/**
+ * Format of the configuration file passed to the benchmark
+ */
+interface Bench2Config {
+ /**
+ * Base URL of the bank.
+ */
+ bank: string;
+
+ /**
+ * Payto url for deposits.
+ */
+ payto: string;
+
+ /**
+ * Base URL of the exchange.
+ */
+ exchange: string;
+
+ /**
+ * How many withdraw/deposit iterations should be made?
+ * Defaults to 1.
+ */
+ iterations?: number;
+
+ currency: string;
+
+ deposits?: number;
+}
+
+/**
+ * Schema validation codec for Bench1Config.
+ */
+const codecForBench2Config = () =>
+ buildCodecForObject<Bench2Config>()
+ .property("bank", codecForString())
+ .property("payto", codecForString())
+ .property("exchange", codecForString())
+ .property("iterations", codecOptional(codecForNumber()))
+ .property("deposits", codecOptional(codecForNumber()))
+ .property("currency", codecForString())
+ .build("Bench2Config");
diff --git a/packages/taler-harness/src/bench3.ts b/packages/taler-harness/src/bench3.ts
new file mode 100644
index 000000000..6041c525c
--- /dev/null
+++ b/packages/taler-harness/src/bench3.ts
@@ -0,0 +1,205 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ buildCodecForObject,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+ j2s,
+ Logger,
+} from "@gnu-taler/taler-util";
+import {
+ getDefaultNodeWallet2,
+ NodeHttpLib,
+ WalletApiOperation,
+ Wallet,
+ AccessStats,
+} from "@gnu-taler/taler-wallet-core";
+import benchMerchantIDGenerator from "./benchMerchantIDGenerator.js";
+
+/**
+ * Entry point for the benchmark.
+ *
+ * The benchmark runs against an existing Taler deployment and does not
+ * set up its own services.
+ */
+export async function runBench3(configJson: any): Promise<void> {
+ const logger = new Logger("Bench3");
+
+ // Validate the configuration file for this benchmark.
+ const b3conf = codecForBench3Config().decode(configJson);
+
+ if (!b3conf.paytoTemplate.includes("${id")) {
+ throw new Error("Payto template url must contain '${id}' placeholder");
+ }
+
+ const myHttpLib = new NodeHttpLib();
+ myHttpLib.setThrottling(false);
+
+ const numIter = b3conf.iterations ?? 1;
+ const numDeposits = b3conf.deposits ?? 5;
+ const restartWallet = b3conf.restartAfter ?? 20;
+
+ const withdrawAmount = (numDeposits + 1) * 10;
+
+ const IDGenerator = benchMerchantIDGenerator(b3conf.randomAlg, b3conf.numMerchants ?? 100);
+
+ logger.info(
+ `Starting Benchmark iterations=${numIter} deposits=${numDeposits} with ${b3conf.randomAlg} merchant selection`,
+ );
+
+ const trustExchange = !!process.env["TALER_WALLET_INSECURE_TRUST_EXCHANGE"];
+ if (trustExchange) {
+ logger.info("trusting exchange (not validating signatures)");
+ } else {
+ logger.info("not trusting exchange (validating signatures)");
+ }
+ const batchWithdrawal = !!process.env["TALER_WALLET_BATCH_WITHDRAWAL"];
+
+ let wallet = {} as Wallet;
+ let getDbStats: () => AccessStats;
+
+ for (let i = 0; i < numIter; i++) {
+ // Create a new wallet in each iteration
+ // otherwise the TPS go down
+ // my assumption is that the in-memory db file gets too large
+ if (i % restartWallet == 0) {
+ if (Object.keys(wallet).length !== 0) {
+ wallet.stop();
+ console.log("wallet DB stats", j2s(getDbStats!()));
+ }
+
+ const res = await getDefaultNodeWallet2({
+ // No persistent DB storage.
+ persistentStoragePath: undefined,
+ httpLib: myHttpLib,
+ });
+ wallet = res.wallet;
+ getDbStats = res.getDbStats;
+ if (trustExchange) {
+ wallet.setInsecureTrustExchange();
+ }
+ wallet.setBatchWithdrawal(batchWithdrawal);
+ await wallet.client.call(WalletApiOperation.InitWallet, {});
+ }
+
+ logger.trace(`Starting withdrawal amount=${withdrawAmount}`);
+ let start = Date.now();
+
+ await wallet.client.call(WalletApiOperation.WithdrawFakebank, {
+ amount: b3conf.currency + ":" + withdrawAmount,
+ bank: b3conf.bank,
+ exchange: b3conf.exchange,
+ });
+
+ await wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+
+ logger.info(
+ `Finished withdrawal amount=${withdrawAmount} time=${Date.now() - start}`,
+ );
+
+ for (let i = 0; i < numDeposits; i++) {
+ logger.trace(`Starting deposit amount=10`);
+ start = Date.now();
+
+ let merchID = IDGenerator.getRandomMerchantID();
+ let payto = b3conf.paytoTemplate.replace("${id}", merchID.toString());
+
+ await wallet.client.call(WalletApiOperation.CreateDepositGroup, {
+ amount: b3conf.currency + ":10",
+ depositPaytoUri: payto,
+ });
+
+ await wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+
+ logger.info(`Finished deposit amount=10 time=${Date.now() - start}`);
+ }
+ }
+
+ wallet.stop();
+ console.log("wallet DB stats", j2s(getDbStats!()));
+}
+
+/**
+ * Format of the configuration file passed to the benchmark
+ */
+interface Bench3Config {
+ /**
+ * Base URL of the bank.
+ */
+ bank: string;
+
+ /**
+ * Payto url template for deposits, must contain '${id}' for replacements.
+ */
+ paytoTemplate: string;
+
+ /**
+ * Base URL of the exchange.
+ */
+ exchange: string;
+
+ /**
+ * How many withdraw/deposit iterations should be made?
+ * Defaults to 1.
+ */
+ iterations?: number;
+
+ currency: string;
+
+ deposits?: number;
+
+ /**
+ * How any iterations run until the wallet db gets purged
+ * Defaults to 20.
+ */
+ restartAfter?: number;
+
+ /**
+ * Number of merchants to select from randomly
+ */
+ numMerchants?: number;
+
+ /**
+ * Which random generator to use.
+ * Possible values: 'zipf', 'rand'
+ */
+ randomAlg: string;
+}
+
+/**
+ * Schema validation codec for Bench1Config.
+ */
+const codecForBench3Config = () =>
+ buildCodecForObject<Bench3Config>()
+ .property("bank", codecForString())
+ .property("paytoTemplate", codecForString())
+ .property("numMerchants", codecOptional(codecForNumber()))
+ .property("randomAlg", codecForString())
+ .property("exchange", codecForString())
+ .property("iterations", codecOptional(codecForNumber()))
+ .property("deposits", codecOptional(codecForNumber()))
+ .property("currency", codecForString())
+ .property("restartAfter", codecOptional(codecForNumber()))
+ .build("Bench1Config");
diff --git a/packages/taler-harness/src/benchMerchantIDGenerator.ts b/packages/taler-harness/src/benchMerchantIDGenerator.ts
new file mode 100644
index 000000000..b83c12bb8
--- /dev/null
+++ b/packages/taler-harness/src/benchMerchantIDGenerator.ts
@@ -0,0 +1,83 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author: Boss Marco
+ */
+
+const getRandomInt = function(max: number) {
+ return Math.floor(Math.random() * max);
+}
+
+abstract class BenchMerchantIDGenerator {
+ abstract getRandomMerchantID(): number
+}
+
+class ZipfGenerator extends BenchMerchantIDGenerator {
+
+ weights: number[];
+ total_weight: number;
+
+ constructor(numMerchants: number) {
+ super();
+ this.weights = new Array<number>(numMerchants);
+ for (var i = 0; i < this.weights.length; i++) {
+ /* we use integers (floor), make sure we have big enough values
+ * by multiplying with
+ * numMerchants again */
+ this.weights[i] = Math.floor((numMerchants/(i+1)) * numMerchants);
+ }
+ this.total_weight = this.weights.reduce((p, n) => p + n);
+ }
+
+ getRandomMerchantID(): number {
+ let random = getRandomInt(this.total_weight);
+ let current = 0;
+
+ for (var i = 0; i < this.weights.length; i++) {
+ current += this.weights[i];
+ if (random <= current) {
+ return i+1;
+ }
+ }
+
+ /* should never come here */
+ return getRandomInt(this.weights.length);
+ }
+}
+
+class RandomGenerator extends BenchMerchantIDGenerator {
+
+ max: number
+
+ constructor(numMerchants: number) {
+ super();
+ this.max = numMerchants
+ }
+
+ getRandomMerchantID() {
+ return getRandomInt(this.max);
+ }
+}
+
+export default function(type: string, maxID: number): BenchMerchantIDGenerator {
+ switch (type) {
+ case "zipf":
+ return new ZipfGenerator(maxID);
+ case "rand":
+ return new RandomGenerator(maxID);
+ default:
+ throw new Error("Valid types are 'zipf' and 'rand'");
+ }
+}
diff --git a/packages/taler-harness/src/env-full.ts b/packages/taler-harness/src/env-full.ts
new file mode 100644
index 000000000..3a684db0b
--- /dev/null
+++ b/packages/taler-harness/src/env-full.ts
@@ -0,0 +1,101 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { Duration, j2s, URL } from "@gnu-taler/taler-util";
+import { CoinConfig, defaultCoinConfig } from "./harness/denomStructures.js";
+import {
+ GlobalTestState,
+ setupDb,
+ ExchangeService,
+ FakebankService,
+ MerchantService,
+ getPayto,
+} from "./harness/harness.js";
+
+/**
+ * Entry point for the full Taler test environment.
+ */
+export async function runEnvFull(t: GlobalTestState): Promise<void> {
+ const db = await setupDb(t);
+
+ const bank = await FakebankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ console.log("exchange bank account", j2s(exchangeBankAccount));
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstance({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ console.log("setup done!");
+}
diff --git a/packages/taler-harness/src/env1.ts b/packages/taler-harness/src/env1.ts
new file mode 100644
index 000000000..aec0b7b8f
--- /dev/null
+++ b/packages/taler-harness/src/env1.ts
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { URL } from "@gnu-taler/taler-util";
+import { CoinConfig, defaultCoinConfig } from "./harness/denomStructures.js";
+import {
+ GlobalTestState,
+ setupDb,
+ ExchangeService,
+ FakebankService,
+} from "./harness/harness.js";
+
+/**
+ * Entry point for the benchmark.
+ *
+ * The benchmark runs against an existing Taler deployment and does not
+ * set up its own services.
+ */
+export async function runEnv1(t: GlobalTestState): Promise<void> {
+ const db = await setupDb(t);
+
+ const bank = await FakebankService.create(t, {
+ currency: "TESTKUDOS",
+ httpPort: 8082,
+ allowRegistrations: true,
+ database: db.connStr,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ exchange.addBankAccount("1", {
+ accountName: "exchange",
+ accountPassword: "x",
+ wireGatewayApiBaseUrl: new URL("/exchange/", bank.baseUrl).href,
+ accountPaytoUri: "payto://x-taler-bank/localhost/exchange",
+ });
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ console.log("setup done!");
+}
diff --git a/packages/taler-harness/src/harness/denomStructures.ts b/packages/taler-harness/src/harness/denomStructures.ts
new file mode 100644
index 000000000..b12857c7e
--- /dev/null
+++ b/packages/taler-harness/src/harness/denomStructures.ts
@@ -0,0 +1,157 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+export interface CoinCoinfigCommon {
+ name: string;
+ value: string;
+ durationWithdraw: string;
+ durationSpend: string;
+ durationLegal: string;
+ feeWithdraw: string;
+ feeDeposit: string;
+ feeRefresh: string;
+ feeRefund: string;
+ ageRestricted?: boolean;
+}
+
+export interface CoinConfigRsa extends CoinCoinfigCommon {
+ cipher: "RSA";
+ rsaKeySize: number;
+}
+
+/**
+ * Clause Schnorr coin config.
+ */
+export interface CoinConfigCs extends CoinCoinfigCommon {
+ cipher: "CS";
+}
+
+export type CoinConfig = CoinConfigRsa | CoinConfigCs;
+
+const coinRsaCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ rsaKeySize: 1024,
+};
+
+export const coin_ct1 = (curr: string): CoinConfig => ({
+ ...coinRsaCommon,
+ name: `${curr}_ct1`,
+ value: `${curr}:0.01`,
+ feeDeposit: `${curr}:0.00`,
+ feeRefresh: `${curr}:0.01`,
+ feeRefund: `${curr}:0.00`,
+ feeWithdraw: `${curr}:0.01`,
+});
+
+export const coin_ct10 = (curr: string): CoinConfig => ({
+ ...coinRsaCommon,
+ name: `${curr}_ct10`,
+ value: `${curr}:0.10`,
+ feeDeposit: `${curr}:0.01`,
+ feeRefresh: `${curr}:0.01`,
+ feeRefund: `${curr}:0.00`,
+ feeWithdraw: `${curr}:0.01`,
+});
+
+export const coin_u1 = (curr: string): CoinConfig => ({
+ ...coinRsaCommon,
+ name: `${curr}_u1`,
+ value: `${curr}:1`,
+ feeDeposit: `${curr}:0.02`,
+ feeRefresh: `${curr}:0.02`,
+ feeRefund: `${curr}:0.02`,
+ feeWithdraw: `${curr}:0.02`,
+});
+
+export const coin_u2 = (curr: string): CoinConfig => ({
+ ...coinRsaCommon,
+ name: `${curr}_u2`,
+ value: `${curr}:2`,
+ feeDeposit: `${curr}:0.02`,
+ feeRefresh: `${curr}:0.02`,
+ feeRefund: `${curr}:0.02`,
+ feeWithdraw: `${curr}:0.02`,
+});
+
+export const coin_u4 = (curr: string): CoinConfig => ({
+ ...coinRsaCommon,
+ name: `${curr}_u4`,
+ value: `${curr}:4`,
+ feeDeposit: `${curr}:0.02`,
+ feeRefresh: `${curr}:0.02`,
+ feeRefund: `${curr}:0.02`,
+ feeWithdraw: `${curr}:0.02`,
+});
+
+export const coin_u8 = (curr: string): CoinConfig => ({
+ ...coinRsaCommon,
+ name: `${curr}_u8`,
+ value: `${curr}:8`,
+ feeDeposit: `${curr}:0.16`,
+ feeRefresh: `${curr}:0.16`,
+ feeRefund: `${curr}:0.16`,
+ feeWithdraw: `${curr}:0.16`,
+});
+
+const coin_u10 = (curr: string): CoinConfig => ({
+ ...coinRsaCommon,
+ name: `${curr}_u10`,
+ value: `${curr}:10`,
+ feeDeposit: `${curr}:0.2`,
+ feeRefresh: `${curr}:0.2`,
+ feeRefund: `${curr}:0.2`,
+ feeWithdraw: `${curr}:0.2`,
+});
+
+export const defaultCoinConfig = [
+ coin_ct1,
+ coin_ct10,
+ coin_u1,
+ coin_u2,
+ coin_u4,
+ coin_u8,
+ coin_u10,
+];
+
+export function makeNoFeeCoinConfig(curr: string): CoinConfig[] {
+ const cc: CoinConfig[] = [];
+
+ for (let i = 0; i < 16; i++) {
+ const ct = 2 ** i;
+
+ const unit = Math.floor(ct / 100);
+ const cent = ct % 100;
+
+ cc.push({
+ cipher: "RSA",
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ rsaKeySize: 1024,
+ name: `${curr}-u${i}`,
+ feeDeposit: `${curr}:0`,
+ feeRefresh: `${curr}:0`,
+ feeRefund: `${curr}:0`,
+ feeWithdraw: `${curr}:0`,
+ value: `${curr}:${unit}.${cent}`,
+ });
+ }
+
+ return cc;
+}
diff --git a/packages/taler-harness/src/harness/faultInjection.ts b/packages/taler-harness/src/harness/faultInjection.ts
new file mode 100644
index 000000000..4c3d0c123
--- /dev/null
+++ b/packages/taler-harness/src/harness/faultInjection.ts
@@ -0,0 +1,256 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Fault injection proxy.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports
+ */
+import * as http from "http";
+import { URL } from "url";
+import {
+ GlobalTestState,
+ ExchangeService,
+ ExchangeServiceInterface,
+ MerchantServiceInterface,
+ MerchantService,
+} from "../harness/harness.js";
+
+export interface FaultProxyConfig {
+ inboundPort: number;
+ targetPort: number;
+}
+
+/**
+ * Fault injection context. Modified by fault injection functions.
+ */
+export interface FaultInjectionRequestContext {
+ requestUrl: string;
+ method: string;
+ requestHeaders: Record<string, string | string[] | undefined>;
+ requestBody?: Buffer;
+ dropRequest: boolean;
+}
+
+export interface FaultInjectionResponseContext {
+ request: FaultInjectionRequestContext;
+ statusCode: number;
+ responseHeaders: Record<string, string | string[] | undefined>;
+ responseBody: Buffer | undefined;
+ dropResponse: boolean;
+}
+
+export interface FaultSpec {
+ modifyRequest?: (ctx: FaultInjectionRequestContext) => Promise<void>;
+ modifyResponse?: (ctx: FaultInjectionResponseContext) => Promise<void>;
+}
+
+export class FaultProxy {
+ constructor(
+ private globalTestState: GlobalTestState,
+ private faultProxyConfig: FaultProxyConfig,
+ ) {}
+
+ private currentFaultSpecs: FaultSpec[] = [];
+
+ start() {
+ const server = http.createServer((req, res) => {
+ const requestChunks: Buffer[] = [];
+ const requestUrl = `http://localhost:${this.faultProxyConfig.inboundPort}${req.url}`;
+ console.log("request for", new URL(requestUrl));
+ req.on("data", (chunk) => {
+ requestChunks.push(chunk);
+ });
+ req.on("end", async () => {
+ console.log("end of data");
+ let requestBuffer: Buffer | undefined;
+ if (requestChunks.length > 0) {
+ requestBuffer = Buffer.concat(requestChunks);
+ }
+ console.log("full request body", requestBuffer);
+
+ const faultReqContext: FaultInjectionRequestContext = {
+ dropRequest: false,
+ method: req.method!!,
+ requestHeaders: req.headers,
+ requestUrl,
+ requestBody: requestBuffer,
+ };
+
+ for (const faultSpec of this.currentFaultSpecs) {
+ if (faultSpec.modifyRequest) {
+ await faultSpec.modifyRequest(faultReqContext);
+ }
+ }
+
+ if (faultReqContext.dropRequest) {
+ res.destroy();
+ return;
+ }
+
+ const faultedUrl = new URL(faultReqContext.requestUrl);
+
+ const proxyRequest = http.request({
+ method: faultReqContext.method,
+ host: "localhost",
+ port: this.faultProxyConfig.targetPort,
+ path: faultedUrl.pathname + faultedUrl.search,
+ headers: faultReqContext.requestHeaders,
+ });
+
+ console.log(
+ `proxying request to target path '${
+ faultedUrl.pathname + faultedUrl.search
+ }'`,
+ );
+
+ if (faultReqContext.requestBody) {
+ proxyRequest.write(faultReqContext.requestBody);
+ }
+ proxyRequest.end();
+ proxyRequest.on("response", (proxyResp) => {
+ console.log("gotten response from target", proxyResp.statusCode);
+ const respChunks: Buffer[] = [];
+ proxyResp.on("data", (proxyRespData) => {
+ respChunks.push(proxyRespData);
+ });
+ proxyResp.on("end", async () => {
+ console.log("end of target response");
+ let responseBuffer: Buffer | undefined;
+ if (respChunks.length > 0) {
+ responseBuffer = Buffer.concat(respChunks);
+ }
+ const faultRespContext: FaultInjectionResponseContext = {
+ request: faultReqContext,
+ dropResponse: false,
+ responseBody: responseBuffer,
+ responseHeaders: proxyResp.headers,
+ statusCode: proxyResp.statusCode!!,
+ };
+ for (const faultSpec of this.currentFaultSpecs) {
+ const modResponse = faultSpec.modifyResponse;
+ if (modResponse) {
+ await modResponse(faultRespContext);
+ }
+ }
+ if (faultRespContext.dropResponse) {
+ req.destroy();
+ return;
+ }
+ if (faultRespContext.responseBody) {
+ // We must accommodate for potentially changed content length
+ faultRespContext.responseHeaders[
+ "content-length"
+ ] = `${faultRespContext.responseBody.byteLength}`;
+ }
+ console.log("writing response head");
+ res.writeHead(
+ faultRespContext.statusCode,
+ http.STATUS_CODES[faultRespContext.statusCode],
+ faultRespContext.responseHeaders,
+ );
+ if (faultRespContext.responseBody) {
+ res.write(faultRespContext.responseBody);
+ }
+ res.end();
+ });
+ });
+ });
+ });
+
+ server.listen(this.faultProxyConfig.inboundPort);
+ this.globalTestState.servers.push(server);
+ }
+
+ addFault(f: FaultSpec) {
+ this.currentFaultSpecs.push(f);
+ }
+
+ clearAllFaults() {
+ this.currentFaultSpecs = [];
+ }
+}
+
+export class FaultInjectedExchangeService implements ExchangeServiceInterface {
+ baseUrl: string;
+ port: number;
+ faultProxy: FaultProxy;
+
+ get name(): string {
+ return this.innerExchange.name;
+ }
+
+ get masterPub(): string {
+ return this.innerExchange.masterPub;
+ }
+
+ private innerExchange: ExchangeService;
+
+ constructor(
+ t: GlobalTestState,
+ e: ExchangeService,
+ proxyInboundPort: number,
+ ) {
+ this.innerExchange = e;
+ this.faultProxy = new FaultProxy(t, {
+ inboundPort: proxyInboundPort,
+ targetPort: e.port,
+ });
+ this.faultProxy.start();
+
+ const exchangeUrl = new URL(e.baseUrl);
+ exchangeUrl.port = `${proxyInboundPort}`;
+ this.baseUrl = exchangeUrl.href;
+ this.port = proxyInboundPort;
+ }
+}
+
+export class FaultInjectedMerchantService implements MerchantServiceInterface {
+ baseUrl: string;
+ port: number;
+ faultProxy: FaultProxy;
+
+ get name(): string {
+ return this.innerMerchant.name;
+ }
+
+ private innerMerchant: MerchantService;
+ private inboundPort: number;
+
+ constructor(
+ t: GlobalTestState,
+ m: MerchantService,
+ proxyInboundPort: number,
+ ) {
+ this.innerMerchant = m;
+ this.faultProxy = new FaultProxy(t, {
+ inboundPort: proxyInboundPort,
+ targetPort: m.port,
+ });
+ this.faultProxy.start();
+ this.inboundPort = proxyInboundPort;
+ }
+
+ makeInstanceBaseUrl(instanceName?: string | undefined): string {
+ const url = new URL(this.innerMerchant.makeInstanceBaseUrl(instanceName));
+ url.port = `${this.inboundPort}`;
+ return url.href;
+ }
+}
diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts
new file mode 100644
index 000000000..6f722dc8d
--- /dev/null
+++ b/packages/taler-harness/src/harness/harness.ts
@@ -0,0 +1,2024 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Test harness for various GNU Taler components.
+ * Also provides a fault-injection proxy.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+const logger = new Logger("harness.ts");
+
+/**
+ * Imports
+ */
+import {
+ AmountJson,
+ Amounts,
+ AmountString,
+ Configuration,
+ CoreApiResponse,
+ createEddsaKeyPair,
+ Duration,
+ eddsaGetPublic,
+ EddsaKeyPair,
+ encodeCrock,
+ hash,
+ j2s,
+ Logger,
+ parsePaytoUri,
+ stringToBytes,
+ TalerProtocolDuration,
+} from "@gnu-taler/taler-util";
+import {
+ BankAccessApi,
+ BankApi,
+ BankServiceHandle,
+ HarnessExchangeBankAccount,
+ NodeHttpLib,
+ openPromise,
+ TalerError,
+ WalletCoreApiClient,
+} from "@gnu-taler/taler-wallet-core";
+import { deepStrictEqual } from "assert";
+import axiosImp, { AxiosError } from "axios";
+import { ChildProcess, spawn } from "child_process";
+import * as child_process from "child_process";
+import * as fs from "fs";
+import * as http from "http";
+import * as path from "path";
+import * as readline from "readline";
+import { URL } from "url";
+import * as util from "util";
+import { CoinConfig } from "./denomStructures.js";
+import { LibeufinNexusApi, LibeufinSandboxApi } from "./libeufin-apis.js";
+import {
+ codecForMerchantOrderPrivateStatusResponse,
+ codecForPostOrderResponse,
+ MerchantInstancesResponse,
+ MerchantOrderPrivateStatusResponse,
+ PostOrderRequest,
+ PostOrderResponse,
+ TipCreateConfirmation,
+ TipCreateRequest,
+ TippingReserveStatus,
+} from "./merchantApiTypes.js";
+
+const exec = util.promisify(child_process.exec);
+
+const axios = axiosImp.default;
+
+export async function delayMs(ms: number): Promise<void> {
+ return new Promise((resolve, reject) => {
+ setTimeout(() => resolve(), ms);
+ });
+}
+
+export interface WithAuthorization {
+ Authorization?: string;
+}
+
+interface WaitResult {
+ code: number | null;
+ signal: NodeJS.Signals | null;
+}
+
+/**
+ * Run a shell command, return stdout.
+ */
+export async function sh(
+ t: GlobalTestState,
+ logName: string,
+ command: string,
+ env: { [index: string]: string | undefined } = process.env,
+): Promise<string> {
+ logger.info(`running command ${command}`);
+ return new Promise((resolve, reject) => {
+ const stdoutChunks: Buffer[] = [];
+ const proc = spawn(command, {
+ stdio: ["inherit", "pipe", "pipe"],
+ shell: true,
+ env: env,
+ });
+ proc.stdout.on("data", (x) => {
+ if (x instanceof Buffer) {
+ stdoutChunks.push(x);
+ } else {
+ throw Error("unexpected data chunk type");
+ }
+ });
+ const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
+ const stderrLog = fs.createWriteStream(stderrLogFileName, {
+ flags: "a",
+ });
+ proc.stderr.pipe(stderrLog);
+ proc.on("exit", (code, signal) => {
+ logger.info(`child process exited (${code} / ${signal})`);
+ if (code != 0) {
+ reject(Error(`Unexpected exit code ${code} for '${command}'`));
+ return;
+ }
+ const b = Buffer.concat(stdoutChunks).toString("utf-8");
+ resolve(b);
+ });
+ proc.on("error", () => {
+ reject(Error("Child process had error"));
+ });
+ });
+}
+
+function shellescape(args: string[]) {
+ const ret = args.map((s) => {
+ if (/[^A-Za-z0-9_\/:=-]/.test(s)) {
+ s = "'" + s.replace(/'/g, "'\\''") + "'";
+ s = s.replace(/^(?:'')+/g, "").replace(/\\'''/g, "\\'");
+ }
+ return s;
+ });
+ return ret.join(" ");
+}
+
+/**
+ * Run a shell command, return stdout.
+ *
+ * Log stderr to a log file.
+ */
+export async function runCommand(
+ t: GlobalTestState,
+ logName: string,
+ command: string,
+ args: string[],
+ env: { [index: string]: string | undefined } = process.env,
+): Promise<string> {
+ logger.info(`running command ${shellescape([command, ...args])}`);
+ return new Promise((resolve, reject) => {
+ const stdoutChunks: Buffer[] = [];
+ const proc = spawn(command, args, {
+ stdio: ["inherit", "pipe", "pipe"],
+ shell: false,
+ env: env,
+ });
+ proc.stdout.on("data", (x) => {
+ if (x instanceof Buffer) {
+ stdoutChunks.push(x);
+ } else {
+ throw Error("unexpected data chunk type");
+ }
+ });
+ const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
+ const stderrLog = fs.createWriteStream(stderrLogFileName, {
+ flags: "a",
+ });
+ proc.stderr.pipe(stderrLog);
+ proc.on("exit", (code, signal) => {
+ logger.info(`child process exited (${code} / ${signal})`);
+ if (code != 0) {
+ reject(Error(`Unexpected exit code ${code} for '${command}'`));
+ return;
+ }
+ const b = Buffer.concat(stdoutChunks).toString("utf-8");
+ resolve(b);
+ });
+ proc.on("error", () => {
+ reject(Error("Child process had error"));
+ });
+ });
+}
+
+export class ProcessWrapper {
+ private waitPromise: Promise<WaitResult>;
+ constructor(public proc: ChildProcess) {
+ this.waitPromise = new Promise((resolve, reject) => {
+ proc.on("exit", (code, signal) => {
+ resolve({ code, signal });
+ });
+ proc.on("error", (err) => {
+ reject(err);
+ });
+ });
+ }
+
+ wait(): Promise<WaitResult> {
+ return this.waitPromise;
+ }
+}
+
+export class GlobalTestParams {
+ testDir: string;
+}
+
+export class GlobalTestState {
+ testDir: string;
+ procs: ProcessWrapper[];
+ servers: http.Server[];
+ inShutdown: boolean = false;
+ constructor(params: GlobalTestParams) {
+ this.testDir = params.testDir;
+ this.procs = [];
+ this.servers = [];
+ }
+
+ async assertThrowsTalerErrorAsync(
+ block: () => Promise<void>,
+ ): Promise<TalerError> {
+ try {
+ await block();
+ } catch (e) {
+ if (e instanceof TalerError) {
+ return e;
+ }
+ throw Error(`expected TalerError to be thrown, but got ${e}`);
+ }
+ throw Error(
+ `expected TalerError to be thrown, but block finished without throwing`,
+ );
+ }
+
+ async assertThrowsAsync(block: () => Promise<void>): Promise<any> {
+ try {
+ await block();
+ } catch (e) {
+ return e;
+ }
+ throw Error(
+ `expected exception to be thrown, but block finished without throwing`,
+ );
+ }
+
+ assertAxiosError(e: any): asserts e is AxiosError {
+ if (!e.isAxiosError) {
+ throw Error("expected axios error");
+ }
+ }
+
+ assertTrue(b: boolean): asserts b {
+ if (!b) {
+ throw Error("test assertion failed");
+ }
+ }
+
+ assertDeepEqual<T>(actual: any, expected: T): asserts actual is T {
+ deepStrictEqual(actual, expected);
+ }
+
+ assertAmountEquals(
+ amtActual: string | AmountJson,
+ amtExpected: string | AmountJson,
+ ): void {
+ if (Amounts.cmp(amtActual, amtExpected) != 0) {
+ throw Error(
+ `test assertion failed: expected ${Amounts.stringify(
+ amtExpected,
+ )} but got ${Amounts.stringify(amtActual)}`,
+ );
+ }
+ }
+
+ assertAmountLeq(a: string | AmountJson, b: string | AmountJson): void {
+ if (Amounts.cmp(a, b) > 0) {
+ throw Error(
+ `test assertion failed: expected ${Amounts.stringify(
+ a,
+ )} to be less or equal (leq) than ${Amounts.stringify(b)}`,
+ );
+ }
+ }
+
+ shutdownSync(): void {
+ for (const s of this.servers) {
+ s.close();
+ s.removeAllListeners();
+ }
+ for (const p of this.procs) {
+ if (p.proc.exitCode == null) {
+ p.proc.kill("SIGTERM");
+ }
+ }
+ }
+
+ spawnService(
+ command: string,
+ args: string[],
+ logName: string,
+ env: { [index: string]: string | undefined } = process.env,
+ ): ProcessWrapper {
+ logger.info(
+ `spawning process (${logName}): ${shellescape([command, ...args])}`,
+ );
+ const proc = spawn(command, args, {
+ stdio: ["inherit", "pipe", "pipe"],
+ env: env,
+ });
+ logger.info(`spawned process (${logName}) with pid ${proc.pid}`);
+ proc.on("error", (err) => {
+ logger.warn(`could not start process (${command})`, err);
+ });
+ proc.on("exit", (code, signal) => {
+ logger.warn(`process ${logName} exited ${j2s({ code, signal })}`);
+ });
+ const stderrLogFileName = this.testDir + `/${logName}-stderr.log`;
+ const stderrLog = fs.createWriteStream(stderrLogFileName, {
+ flags: "a",
+ });
+ proc.stderr.pipe(stderrLog);
+ const stdoutLogFileName = this.testDir + `/${logName}-stdout.log`;
+ const stdoutLog = fs.createWriteStream(stdoutLogFileName, {
+ flags: "a",
+ });
+ proc.stdout.pipe(stdoutLog);
+ const procWrap = new ProcessWrapper(proc);
+ this.procs.push(procWrap);
+ return procWrap;
+ }
+
+ async shutdown(): Promise<void> {
+ if (this.inShutdown) {
+ return;
+ }
+ if (shouldLingerInTest()) {
+ logger.info("refusing to shut down, lingering was requested");
+ return;
+ }
+ this.inShutdown = true;
+ logger.info("shutting down");
+ for (const s of this.servers) {
+ s.close();
+ s.removeAllListeners();
+ }
+ for (const p of this.procs) {
+ if (p.proc.exitCode == null) {
+ logger.info(`killing process ${p.proc.pid}`);
+ p.proc.kill("SIGTERM");
+ await p.wait();
+ }
+ }
+ }
+}
+
+export function shouldLingerInTest(): boolean {
+ return !!process.env["TALER_TEST_LINGER"];
+}
+
+export interface TalerConfigSection {
+ options: Record<string, string | undefined>;
+}
+
+export interface TalerConfig {
+ sections: Record<string, TalerConfigSection>;
+}
+
+export interface DbInfo {
+ /**
+ * Postgres connection string.
+ */
+ connStr: string;
+
+ dbname: string;
+}
+
+export async function setupDb(gc: GlobalTestState): Promise<DbInfo> {
+ const dbname = "taler-integrationtest";
+ await exec(`dropdb "${dbname}" || true`);
+ await exec(`createdb "${dbname}"`);
+ return {
+ connStr: `postgres:///${dbname}`,
+ dbname,
+ };
+}
+
+export interface BankConfig {
+ currency: string;
+ httpPort: number;
+ database: string;
+ allowRegistrations: boolean;
+ maxDebt?: string;
+}
+
+export interface FakeBankConfig {
+ currency: string;
+ httpPort: number;
+}
+
+function setTalerPaths(config: Configuration, home: string) {
+ config.setString("paths", "taler_home", home);
+ // We need to make sure that the path of taler_runtime_dir isn't too long,
+ // as it contains unix domain sockets (108 character limit).
+ const runDir = fs.mkdtempSync("/tmp/taler-test-");
+ config.setString("paths", "taler_runtime_dir", runDir);
+ config.setString(
+ "paths",
+ "taler_data_home",
+ "$TALER_HOME/.local/share/taler/",
+ );
+ config.setString("paths", "taler_config_home", "$TALER_HOME/.config/taler/");
+ config.setString("paths", "taler_cache_home", "$TALER_HOME/.config/taler/");
+}
+
+function setCoin(config: Configuration, c: CoinConfig) {
+ const s = `coin_${c.name}`;
+ config.setString(s, "value", c.value);
+ config.setString(s, "duration_withdraw", c.durationWithdraw);
+ config.setString(s, "duration_spend", c.durationSpend);
+ config.setString(s, "duration_legal", c.durationLegal);
+ config.setString(s, "fee_deposit", c.feeDeposit);
+ config.setString(s, "fee_withdraw", c.feeWithdraw);
+ config.setString(s, "fee_refresh", c.feeRefresh);
+ config.setString(s, "fee_refund", c.feeRefund);
+ if (c.ageRestricted) {
+ config.setString(s, "age_restricted", "yes");
+ }
+ if (c.cipher === "RSA") {
+ config.setString(s, "rsa_keysize", `${c.rsaKeySize}`);
+ config.setString(s, "cipher", "RSA");
+ } else if (c.cipher === "CS") {
+ config.setString(s, "cipher", "CS");
+ } else {
+ throw new Error();
+ }
+}
+
+/**
+ * Send an HTTP request until it succeeds or the process dies.
+ */
+export async function pingProc(
+ proc: ProcessWrapper | undefined,
+ url: string,
+ serviceName: string,
+): Promise<void> {
+ if (!proc || proc.proc.exitCode !== null) {
+ throw Error(`service process ${serviceName} not started, can't ping`);
+ }
+ while (true) {
+ try {
+ logger.info(`pinging ${serviceName} at ${url}`);
+ const resp = await axios.get(url);
+ logger.info(`service ${serviceName} available`);
+ return;
+ } catch (e: any) {
+ logger.info(`service ${serviceName} not ready:`, e.toString());
+ //console.log(e);
+ await delayMs(1000);
+ }
+ if (!proc || proc.proc.exitCode != null || proc.proc.signalCode != null) {
+ throw Error(`service process ${serviceName} stopped unexpectedly`);
+ }
+ }
+}
+
+class BankServiceBase {
+ proc: ProcessWrapper | undefined;
+
+ protected constructor(
+ protected globalTestState: GlobalTestState,
+ protected bankConfig: BankConfig,
+ protected configFile: string,
+ ) {}
+}
+
+/**
+ * Work in progress. The key point is that both Sandbox and Nexus
+ * will be configured and started by this class.
+ */
+class LibEuFinBankService extends BankServiceBase implements BankServiceHandle {
+ sandboxProc: ProcessWrapper | undefined;
+ nexusProc: ProcessWrapper | undefined;
+
+ http = new NodeHttpLib();
+
+ static async create(
+ gc: GlobalTestState,
+ bc: BankConfig,
+ ): Promise<LibEuFinBankService> {
+ return new LibEuFinBankService(gc, bc, "foo");
+ }
+
+ get port() {
+ return this.bankConfig.httpPort;
+ }
+ get nexusPort() {
+ return this.bankConfig.httpPort + 1000;
+ }
+
+ get nexusDbConn(): string {
+ return `jdbc:sqlite:${this.globalTestState.testDir}/libeufin-nexus.sqlite3`;
+ }
+
+ get sandboxDbConn(): string {
+ return `jdbc:sqlite:${this.globalTestState.testDir}/libeufin-sandbox.sqlite3`;
+ }
+
+ get nexusBaseUrl(): string {
+ return `http://localhost:${this.nexusPort}`;
+ }
+
+ get baseUrlDemobank(): string {
+ let url = new URL("demobanks/default/", this.baseUrlNetloc);
+ return url.href;
+ }
+
+ // FIXME: Duplicate? Where is this needed?
+ get baseUrlAccessApi(): string {
+ let url = new URL("access-api/", this.baseUrlDemobank);
+ return url.href;
+ }
+
+ get bankAccessApiBaseUrl(): string {
+ let url = new URL("access-api/", this.baseUrlDemobank);
+ return url.href;
+ }
+
+ get baseUrlNetloc(): string {
+ return `http://localhost:${this.bankConfig.httpPort}/`;
+ }
+
+ get baseUrl(): string {
+ return this.baseUrlAccessApi;
+ }
+
+ async setSuggestedExchange(
+ e: ExchangeServiceInterface,
+ exchangePayto: string,
+ ) {
+ await sh(
+ this.globalTestState,
+ "libeufin-sandbox-set-default-exchange",
+ `libeufin-sandbox default-exchange ${e.baseUrl} ${exchangePayto}`,
+ {
+ ...process.env,
+ LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxDbConn,
+ },
+ );
+ }
+
+ // Create one at both sides: Sandbox and Nexus.
+ async createExchangeAccount(
+ accountName: string,
+ password: string,
+ ): Promise<HarnessExchangeBankAccount> {
+ logger.info("Create Exchange account(s)!");
+ /**
+ * Many test cases try to create a Exchange account before
+ * starting the bank; that's because the Pybank did it entirely
+ * via the configuration file.
+ */
+ await this.start();
+ await this.pingUntilAvailable();
+ await LibeufinSandboxApi.createDemobankAccount(accountName, password, {
+ baseUrl: this.baseUrlAccessApi,
+ });
+ let bankAccountLabel = accountName;
+ await LibeufinSandboxApi.createDemobankEbicsSubscriber(
+ {
+ hostID: "talertestEbicsHost",
+ userID: "exchangeEbicsUser",
+ partnerID: "exchangeEbicsPartner",
+ },
+ bankAccountLabel,
+ { baseUrl: this.baseUrlDemobank },
+ );
+
+ await LibeufinNexusApi.createUser(
+ { baseUrl: this.nexusBaseUrl },
+ {
+ username: accountName,
+ password: password,
+ },
+ );
+ await LibeufinNexusApi.createEbicsBankConnection(
+ { baseUrl: this.nexusBaseUrl },
+ {
+ name: "ebics-connection", // connection name.
+ ebicsURL: new URL("ebicsweb", this.baseUrlNetloc).href,
+ hostID: "talertestEbicsHost",
+ userID: "exchangeEbicsUser",
+ partnerID: "exchangeEbicsPartner",
+ },
+ );
+ await LibeufinNexusApi.connectBankConnection(
+ { baseUrl: this.nexusBaseUrl },
+ "ebics-connection",
+ );
+ await LibeufinNexusApi.fetchAccounts(
+ { baseUrl: this.nexusBaseUrl },
+ "ebics-connection",
+ );
+ await LibeufinNexusApi.importConnectionAccount(
+ { baseUrl: this.nexusBaseUrl },
+ "ebics-connection", // connection name
+ accountName, // offered account label
+ `${accountName}-nexus-label`, // bank account label at Nexus
+ );
+ await LibeufinNexusApi.createTwgFacade(
+ { baseUrl: this.nexusBaseUrl },
+ {
+ name: "exchange-facade",
+ connectionName: "ebics-connection",
+ accountName: `${accountName}-nexus-label`,
+ currency: "EUR",
+ reserveTransferLevel: "report",
+ },
+ );
+ await LibeufinNexusApi.postPermission(
+ { baseUrl: this.nexusBaseUrl },
+ {
+ action: "grant",
+ permission: {
+ subjectId: accountName,
+ subjectType: "user",
+ resourceType: "facade",
+ resourceId: "exchange-facade", // facade name
+ permissionName: "facade.talerWireGateway.transfer",
+ },
+ },
+ );
+ await LibeufinNexusApi.postPermission(
+ { baseUrl: this.nexusBaseUrl },
+ {
+ action: "grant",
+ permission: {
+ subjectId: accountName,
+ subjectType: "user",
+ resourceType: "facade",
+ resourceId: "exchange-facade", // facade name
+ permissionName: "facade.talerWireGateway.history",
+ },
+ },
+ );
+ // Set fetch task.
+ await LibeufinNexusApi.postTask(
+ { baseUrl: this.nexusBaseUrl },
+ `${accountName}-nexus-label`,
+ {
+ name: "wirewatch-task",
+ cronspec: "* * *",
+ type: "fetch",
+ params: {
+ level: "all",
+ rangeType: "all",
+ },
+ },
+ );
+ await LibeufinNexusApi.postTask(
+ { baseUrl: this.nexusBaseUrl },
+ `${accountName}-nexus-label`,
+ {
+ name: "aggregator-task",
+ cronspec: "* * *",
+ type: "submit",
+ params: {},
+ },
+ );
+ let facadesResp = await LibeufinNexusApi.getAllFacades({
+ baseUrl: this.nexusBaseUrl,
+ });
+ let accountInfoResp = await LibeufinSandboxApi.demobankAccountInfo(
+ "admin",
+ "secret",
+ { baseUrl: this.baseUrlAccessApi },
+ accountName, // bank account label.
+ );
+ return {
+ accountName: accountName,
+ accountPassword: password,
+ accountPaytoUri: accountInfoResp.data.paytoUri,
+ wireGatewayApiBaseUrl: facadesResp.data.facades[0].baseUrl,
+ };
+ }
+
+ async start(): Promise<void> {
+ /**
+ * Because many test cases try to create a Exchange bank
+ * account _before_ starting the bank (Pybank did it only via
+ * the config), it is possible that at this point Sandbox and
+ * Nexus are already running. Hence, this method only launches
+ * them if they weren't launched earlier.
+ */
+
+ // Only go ahead if BOTH aren't running.
+ if (this.sandboxProc || this.nexusProc) {
+ logger.info("Nexus or Sandbox already running, not taking any action.");
+ return;
+ }
+ await sh(
+ this.globalTestState,
+ "libeufin-sandbox-config-demobank",
+ `libeufin-sandbox config --currency=${this.bankConfig.currency} default`,
+ {
+ ...process.env,
+ LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxDbConn,
+ LIBEUFIN_SANDBOX_ADMIN_PASSWORD: "secret",
+ },
+ );
+ this.sandboxProc = this.globalTestState.spawnService(
+ "libeufin-sandbox",
+ ["serve", "--port", `${this.port}`],
+ "libeufin-sandbox",
+ {
+ ...process.env,
+ LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxDbConn,
+ LIBEUFIN_SANDBOX_ADMIN_PASSWORD: "secret",
+ },
+ );
+ await runCommand(
+ this.globalTestState,
+ "libeufin-nexus-superuser",
+ "libeufin-nexus",
+ ["superuser", "admin", "--password", "test"],
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusDbConn,
+ },
+ );
+ this.nexusProc = this.globalTestState.spawnService(
+ "libeufin-nexus",
+ ["serve", "--port", `${this.nexusPort}`],
+ "libeufin-nexus",
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusDbConn,
+ },
+ );
+ // need to wait here, because at this point
+ // a Ebics host needs to be created (RESTfully)
+ await this.pingUntilAvailable();
+ LibeufinSandboxApi.createEbicsHost(
+ { baseUrl: this.baseUrlNetloc },
+ "talertestEbicsHost",
+ );
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ await pingProc(
+ this.sandboxProc,
+ `http://localhost:${this.bankConfig.httpPort}`,
+ "libeufin-sandbox",
+ );
+ await pingProc(
+ this.nexusProc,
+ `${this.nexusBaseUrl}/config`,
+ "libeufin-nexus",
+ );
+ }
+}
+
+/**
+ * Implementation of the bank service using the "taler-fakebank-run" tool.
+ */
+export class FakebankService
+ extends BankServiceBase
+ implements BankServiceHandle
+{
+ proc: ProcessWrapper | undefined;
+
+ http = new NodeHttpLib();
+
+ // We store "created" accounts during setup and
+ // register them after startup.
+ private accounts: {
+ accountName: string;
+ accountPassword: string;
+ }[] = [];
+
+ static async create(
+ gc: GlobalTestState,
+ bc: BankConfig,
+ ): Promise<FakebankService> {
+ const config = new Configuration();
+ setTalerPaths(config, gc.testDir + "/talerhome");
+ config.setString("taler", "currency", bc.currency);
+ config.setString("bank", "http_port", `${bc.httpPort}`);
+ config.setString("bank", "serve", "http");
+ config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
+ config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`);
+ config.setString("bank", "ram_limit", `${1024}`);
+ const cfgFilename = gc.testDir + "/bank.conf";
+ config.write(cfgFilename);
+
+ return new FakebankService(gc, bc, cfgFilename);
+ }
+
+ setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) {
+ if (!!this.proc) {
+ throw Error("Can't set suggested exchange while bank is running.");
+ }
+ const config = Configuration.load(this.configFile);
+ config.setString("bank", "suggested_exchange", e.baseUrl);
+ config.write(this.configFile);
+ }
+
+ get baseUrl(): string {
+ return `http://localhost:${this.bankConfig.httpPort}/`;
+ }
+
+ get bankAccessApiBaseUrl(): string {
+ let url = new URL("taler-bank-access/", this.baseUrl);
+ return url.href;
+ }
+
+ async createExchangeAccount(
+ accountName: string,
+ password: string,
+ ): Promise<HarnessExchangeBankAccount> {
+ this.accounts.push({
+ accountName,
+ accountPassword: password,
+ });
+ return {
+ accountName: accountName,
+ accountPassword: password,
+ accountPaytoUri: getPayto(accountName),
+ wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/taler-wire-gateway/${accountName}/`,
+ };
+ }
+
+ get port() {
+ return this.bankConfig.httpPort;
+ }
+
+ async start(): Promise<void> {
+ logger.info("starting fakebank");
+ if (this.proc) {
+ logger.info("fakebank already running, not starting again");
+ return;
+ }
+ this.proc = this.globalTestState.spawnService(
+ "taler-fakebank-run",
+ [
+ "-c",
+ this.configFile,
+ "--signup-bonus",
+ `${this.bankConfig.currency}:100`,
+ ],
+ "bank",
+ );
+ await this.pingUntilAvailable();
+ for (const acc of this.accounts) {
+ await BankApi.registerAccount(this, acc.accountName, acc.accountPassword);
+ }
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `http://localhost:${this.bankConfig.httpPort}/taler-bank-integration/config`;
+ await pingProc(this.proc, url, "bank");
+ }
+}
+
+// Use libeufin bank instead of pybank.
+const useLibeufinBank = false;
+
+export type BankService = BankServiceHandle;
+export const BankService = FakebankService;
+
+export interface ExchangeConfig {
+ name: string;
+ currency: string;
+ roundUnit?: string;
+ httpPort: number;
+ database: string;
+}
+
+export interface ExchangeServiceInterface {
+ readonly baseUrl: string;
+ readonly port: number;
+ readonly name: string;
+ readonly masterPub: string;
+}
+
+export class ExchangeService implements ExchangeServiceInterface {
+ static fromExistingConfig(gc: GlobalTestState, exchangeName: string) {
+ const cfgFilename = gc.testDir + `/exchange-${exchangeName}.conf`;
+ const config = Configuration.load(cfgFilename);
+ const ec: ExchangeConfig = {
+ currency: config.getString("taler", "currency").required(),
+ database: config.getString("exchangedb-postgres", "config").required(),
+ httpPort: config.getNumber("exchange", "port").required(),
+ name: exchangeName,
+ roundUnit: config.getString("taler", "currency_round_unit").required(),
+ };
+ const privFile = config.getPath("exchange", "master_priv_file").required();
+ const eddsaPriv = fs.readFileSync(privFile);
+ const keyPair: EddsaKeyPair = {
+ eddsaPriv,
+ eddsaPub: eddsaGetPublic(eddsaPriv),
+ };
+ return new ExchangeService(gc, ec, cfgFilename, keyPair);
+ }
+
+ private currentTimetravel: Duration | undefined;
+
+ setTimetravel(t: Duration | undefined): void {
+ if (this.isRunning()) {
+ throw Error("can't set time travel while the exchange is running");
+ }
+ this.currentTimetravel = t;
+ }
+
+ private get timetravelArg(): string | undefined {
+ if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
+ // Convert to microseconds
+ return `--timetravel=+${this.currentTimetravel.d_ms * 1000}`;
+ }
+ return undefined;
+ }
+
+ /**
+ * Return an empty array if no time travel is set,
+ * and an array with the time travel command line argument
+ * otherwise.
+ */
+ private get timetravelArgArr(): string[] {
+ const tta = this.timetravelArg;
+ if (tta) {
+ return [tta];
+ }
+ return [];
+ }
+
+ async runWirewatchOnce() {
+ if (useLibeufinBank) {
+ // Not even 2 secods showed to be enough!
+ await waitMs(4000);
+ }
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-wirewatch-once`,
+ "taler-exchange-wirewatch",
+ [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+
+ async runAggregatorOnce() {
+ try {
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-aggregator-once`,
+ "taler-exchange-aggregator",
+ [...this.timetravelArgArr, "-c", this.configFilename, "-t", "-y"],
+ );
+ } catch (e) {
+ logger.info(
+ "running aggregator with KYC off didn't work, might be old version, running again",
+ );
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-aggregator-once`,
+ "taler-exchange-aggregator",
+ [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+ }
+
+ async runTransferOnce() {
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-transfer-once`,
+ "taler-exchange-transfer",
+ [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+
+ changeConfig(f: (config: Configuration) => void) {
+ const config = Configuration.load(this.configFilename);
+ f(config);
+ config.write(this.configFilename);
+ }
+
+ static create(gc: GlobalTestState, e: ExchangeConfig) {
+ const config = new Configuration();
+ config.setString("taler", "currency", e.currency);
+ config.setString(
+ "taler",
+ "currency_round_unit",
+ e.roundUnit ?? `${e.currency}:0.01`,
+ );
+ setTalerPaths(config, gc.testDir + "/talerhome");
+ config.setString(
+ "exchange",
+ "revocation_dir",
+ "${TALER_DATA_HOME}/exchange/revocations",
+ );
+ config.setString("exchange", "max_keys_caching", "forever");
+ config.setString("exchange", "db", "postgres");
+ config.setString(
+ "exchange-offline",
+ "master_priv_file",
+ "${TALER_DATA_HOME}/exchange/offline-keys/master.priv",
+ );
+ config.setString("exchange", "serve", "tcp");
+ config.setString("exchange", "port", `${e.httpPort}`);
+
+ config.setString("exchangedb-postgres", "config", e.database);
+
+ config.setString("taler-exchange-secmod-eddsa", "lookahead_sign", "20 s");
+ config.setString("taler-exchange-secmod-rsa", "lookahead_sign", "20 s");
+
+ const exchangeMasterKey = createEddsaKeyPair();
+
+ config.setString(
+ "exchange",
+ "master_public_key",
+ encodeCrock(exchangeMasterKey.eddsaPub),
+ );
+
+ const masterPrivFile = config
+ .getPath("exchange-offline", "master_priv_file")
+ .required();
+
+ fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true });
+
+ fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv));
+
+ const cfgFilename = gc.testDir + `/exchange-${e.name}.conf`;
+ config.write(cfgFilename);
+ return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey);
+ }
+
+ addOfferedCoins(offeredCoins: ((curr: string) => CoinConfig)[]) {
+ const config = Configuration.load(this.configFilename);
+ offeredCoins.forEach((cc) =>
+ setCoin(config, cc(this.exchangeConfig.currency)),
+ );
+ config.write(this.configFilename);
+ }
+
+ addCoinConfigList(ccs: CoinConfig[]) {
+ const config = Configuration.load(this.configFilename);
+ ccs.forEach((cc) => setCoin(config, cc));
+ config.write(this.configFilename);
+ }
+
+ enableAgeRestrictions(maskStr: string) {
+ const config = Configuration.load(this.configFilename);
+ config.setString("exchange-extension-age_restriction", "enabled", "yes");
+ config.setString(
+ "exchange-extension-age_restriction",
+ "age_groups",
+ maskStr,
+ );
+ config.write(this.configFilename);
+ }
+
+ get masterPub() {
+ return encodeCrock(this.keyPair.eddsaPub);
+ }
+
+ get port() {
+ return this.exchangeConfig.httpPort;
+ }
+
+ async addBankAccount(
+ localName: string,
+ exchangeBankAccount: HarnessExchangeBankAccount,
+ ): Promise<void> {
+ const config = Configuration.load(this.configFilename);
+ config.setString(
+ `exchange-account-${localName}`,
+ "wire_response",
+ `\${TALER_DATA_HOME}/exchange/account-${localName}.json`,
+ );
+ config.setString(
+ `exchange-account-${localName}`,
+ "payto_uri",
+ exchangeBankAccount.accountPaytoUri,
+ );
+ config.setString(`exchange-account-${localName}`, "enable_credit", "yes");
+ config.setString(`exchange-account-${localName}`, "enable_debit", "yes");
+ config.setString(
+ `exchange-accountcredentials-${localName}`,
+ "wire_gateway_url",
+ exchangeBankAccount.wireGatewayApiBaseUrl,
+ );
+ config.setString(
+ `exchange-accountcredentials-${localName}`,
+ "wire_gateway_auth_method",
+ "basic",
+ );
+ config.setString(
+ `exchange-accountcredentials-${localName}`,
+ "username",
+ exchangeBankAccount.accountName,
+ );
+ config.setString(
+ `exchange-accountcredentials-${localName}`,
+ "password",
+ exchangeBankAccount.accountPassword,
+ );
+ config.write(this.configFilename);
+ }
+
+ exchangeHttpProc: ProcessWrapper | undefined;
+ exchangeWirewatchProc: ProcessWrapper | undefined;
+
+ helperCryptoRsaProc: ProcessWrapper | undefined;
+ helperCryptoEddsaProc: ProcessWrapper | undefined;
+ helperCryptoCsProc: ProcessWrapper | undefined;
+
+ constructor(
+ private globalState: GlobalTestState,
+ private exchangeConfig: ExchangeConfig,
+ private configFilename: string,
+ private keyPair: EddsaKeyPair,
+ ) {}
+
+ get name() {
+ return this.exchangeConfig.name;
+ }
+
+ get baseUrl() {
+ return `http://localhost:${this.exchangeConfig.httpPort}/`;
+ }
+
+ isRunning(): boolean {
+ return !!this.exchangeWirewatchProc || !!this.exchangeHttpProc;
+ }
+
+ async stop(): Promise<void> {
+ const wirewatch = this.exchangeWirewatchProc;
+ if (wirewatch) {
+ wirewatch.proc.kill("SIGTERM");
+ await wirewatch.wait();
+ this.exchangeWirewatchProc = undefined;
+ }
+ const httpd = this.exchangeHttpProc;
+ if (httpd) {
+ httpd.proc.kill("SIGTERM");
+ await httpd.wait();
+ this.exchangeHttpProc = undefined;
+ }
+ const cryptoRsa = this.helperCryptoRsaProc;
+ if (cryptoRsa) {
+ cryptoRsa.proc.kill("SIGTERM");
+ await cryptoRsa.wait();
+ this.helperCryptoRsaProc = undefined;
+ }
+ const cryptoEddsa = this.helperCryptoEddsaProc;
+ if (cryptoEddsa) {
+ cryptoEddsa.proc.kill("SIGTERM");
+ await cryptoEddsa.wait();
+ this.helperCryptoRsaProc = undefined;
+ }
+ const cryptoCs = this.helperCryptoCsProc;
+ if (cryptoCs) {
+ cryptoCs.proc.kill("SIGTERM");
+ await cryptoCs.wait();
+ this.helperCryptoCsProc = undefined;
+ }
+ }
+
+ /**
+ * Update keys signing the keys generated by the security module
+ * with the offline signing key.
+ */
+ async keyup(): Promise<void> {
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ ["-c", this.configFilename, "download", "sign", "upload"],
+ );
+
+ const accounts: string[] = [];
+ const accountTargetTypes: Set<string> = new Set();
+
+ const config = Configuration.load(this.configFilename);
+ for (const sectionName of config.getSectionNames()) {
+ if (sectionName.startsWith("EXCHANGE-ACCOUNT-")) {
+ const paytoUri = config.getString(sectionName, "payto_uri").required();
+ const p = parsePaytoUri(paytoUri);
+ if (!p) {
+ throw Error(`invalid payto uri in exchange config: ${paytoUri}`);
+ }
+ accountTargetTypes.add(p?.targetType);
+ accounts.push(paytoUri);
+ }
+ }
+
+ logger.info("configuring bank accounts", accounts);
+
+ for (const acc of accounts) {
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ ["-c", this.configFilename, "enable-account", acc, "upload"],
+ );
+ }
+
+ const year = new Date().getFullYear();
+ for (const accTargetType of accountTargetTypes.values()) {
+ for (let i = year; i < year + 5; i++) {
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ "wire-fee",
+ // Year
+ `${i}`,
+ // Wire method
+ accTargetType,
+ // Wire fee
+ `${this.exchangeConfig.currency}:0.01`,
+ // Closing fee
+ `${this.exchangeConfig.currency}:0.01`,
+ "upload",
+ ],
+ );
+ }
+ }
+
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ "global-fee",
+ // year
+ "now",
+ // history fee
+ `${this.exchangeConfig.currency}:0.01`,
+ // account fee
+ `${this.exchangeConfig.currency}:0.01`,
+ // purse fee
+ `${this.exchangeConfig.currency}:0.00`,
+ // purse timeout
+ "1h",
+ // history expiration
+ "1year",
+ // free purses per account
+ "5",
+ "upload",
+ ],
+ );
+ }
+
+ async revokeDenomination(denomPubHash: string) {
+ if (!this.isRunning()) {
+ throw Error("exchange must be running when revoking denominations");
+ }
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ "revoke-denomination",
+ denomPubHash,
+ "upload",
+ ],
+ );
+ }
+
+ async purgeSecmodKeys(): Promise<void> {
+ const cfg = Configuration.load(this.configFilename);
+ const rsaKeydir = cfg
+ .getPath("taler-exchange-secmod-rsa", "KEY_DIR")
+ .required();
+ const eddsaKeydir = cfg
+ .getPath("taler-exchange-secmod-eddsa", "KEY_DIR")
+ .required();
+ // Be *VERY* careful when changing this, or you will accidentally delete user data.
+ await sh(this.globalState, "rm-secmod-keys", `rm -rf ${rsaKeydir}/COIN_*`);
+ await sh(this.globalState, "rm-secmod-keys", `rm ${eddsaKeydir}/*`);
+ }
+
+ async purgeDatabase(): Promise<void> {
+ await sh(
+ this.globalState,
+ "exchange-dbinit",
+ `taler-exchange-dbinit -r -c "${this.configFilename}"`,
+ );
+ }
+
+ async start(): Promise<void> {
+ if (this.isRunning()) {
+ throw Error("exchange is already running");
+ }
+ await sh(
+ this.globalState,
+ "exchange-dbinit",
+ `taler-exchange-dbinit -c "${this.configFilename}"`,
+ );
+
+ this.helperCryptoEddsaProc = this.globalState.spawnService(
+ "taler-exchange-secmod-eddsa",
+ ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
+ `exchange-crypto-eddsa-${this.name}`,
+ );
+
+ this.helperCryptoCsProc = this.globalState.spawnService(
+ "taler-exchange-secmod-cs",
+ ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
+ `exchange-crypto-cs-${this.name}`,
+ );
+
+ this.helperCryptoRsaProc = this.globalState.spawnService(
+ "taler-exchange-secmod-rsa",
+ ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
+ `exchange-crypto-rsa-${this.name}`,
+ );
+
+ this.exchangeWirewatchProc = this.globalState.spawnService(
+ "taler-exchange-wirewatch",
+ ["-c", this.configFilename, ...this.timetravelArgArr],
+ `exchange-wirewatch-${this.name}`,
+ );
+
+ this.exchangeHttpProc = this.globalState.spawnService(
+ "taler-exchange-httpd",
+ ["-LINFO", "-c", this.configFilename, ...this.timetravelArgArr],
+ `exchange-httpd-${this.name}`,
+ );
+
+ await this.pingUntilAvailable();
+ await this.keyup();
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ // We request /management/keys, since /keys can block
+ // when we didn't do the key setup yet.
+ const url = `http://localhost:${this.exchangeConfig.httpPort}/management/keys`;
+ await pingProc(this.exchangeHttpProc, url, `exchange (${this.name})`);
+ }
+}
+
+export interface MerchantConfig {
+ name: string;
+ currency: string;
+ httpPort: number;
+ database: string;
+}
+
+export interface PrivateOrderStatusQuery {
+ instance?: string;
+ orderId: string;
+ sessionId?: string;
+}
+
+export interface MerchantServiceInterface {
+ makeInstanceBaseUrl(instanceName?: string): string;
+ readonly port: number;
+ readonly name: string;
+}
+
+export class MerchantApiClient {
+ constructor(
+ private baseUrl: string,
+ public readonly auth: MerchantAuthConfiguration,
+ ) {}
+
+ async changeAuth(auth: MerchantAuthConfiguration): Promise<void> {
+ const url = new URL("private/auth", this.baseUrl);
+ await axios.post(url.href, auth, {
+ headers: this.makeAuthHeader(),
+ });
+ }
+
+ async deleteInstance(instanceId: string) {
+ const url = new URL(`management/instances/${instanceId}`, this.baseUrl);
+ await axios.delete(url.href, {
+ headers: this.makeAuthHeader(),
+ });
+ }
+
+ async createInstance(req: MerchantInstanceConfig): Promise<void> {
+ const url = new URL("management/instances", this.baseUrl);
+ await axios.post(url.href, req, {
+ headers: this.makeAuthHeader(),
+ });
+ }
+
+ async getInstances(): Promise<MerchantInstancesResponse> {
+ const url = new URL("management/instances", this.baseUrl);
+ const resp = await axios.get(url.href, {
+ headers: this.makeAuthHeader(),
+ });
+ return resp.data;
+ }
+
+ async getInstanceFullDetails(instanceId: string): Promise<any> {
+ const url = new URL(`management/instances/${instanceId}`, this.baseUrl);
+ try {
+ const resp = await axios.get(url.href, {
+ headers: this.makeAuthHeader(),
+ });
+ return resp.data;
+ } catch (e) {
+ throw e;
+ }
+ }
+
+ makeAuthHeader(): Record<string, string> {
+ switch (this.auth.method) {
+ case "external":
+ return {};
+ case "token":
+ return {
+ Authorization: `Bearer ${this.auth.token}`,
+ };
+ }
+ }
+}
+
+/**
+ * FIXME: This should be deprecated in favor of MerchantApiClient
+ */
+export namespace MerchantPrivateApi {
+ export async function createOrder(
+ merchantService: MerchantServiceInterface,
+ instanceName: string,
+ req: PostOrderRequest,
+ withAuthorization: WithAuthorization = {},
+ ): Promise<PostOrderResponse> {
+ const baseUrl = merchantService.makeInstanceBaseUrl(instanceName);
+ let url = new URL("private/orders", baseUrl);
+ const resp = await axios.post(url.href, req, {
+ headers: withAuthorization as Record<string, string>,
+ });
+ return codecForPostOrderResponse().decode(resp.data);
+ }
+
+ export async function queryPrivateOrderStatus(
+ merchantService: MerchantServiceInterface,
+ query: PrivateOrderStatusQuery,
+ withAuthorization: WithAuthorization = {},
+ ): Promise<MerchantOrderPrivateStatusResponse> {
+ const reqUrl = new URL(
+ `private/orders/${query.orderId}`,
+ merchantService.makeInstanceBaseUrl(query.instance),
+ );
+ if (query.sessionId) {
+ reqUrl.searchParams.set("session_id", query.sessionId);
+ }
+ const resp = await axios.get(reqUrl.href, {
+ headers: withAuthorization as Record<string, string>,
+ });
+ return codecForMerchantOrderPrivateStatusResponse().decode(resp.data);
+ }
+
+ export async function giveRefund(
+ merchantService: MerchantServiceInterface,
+ r: {
+ instance: string;
+ orderId: string;
+ amount: string;
+ justification: string;
+ },
+ ): Promise<{ talerRefundUri: string }> {
+ const reqUrl = new URL(
+ `private/orders/${r.orderId}/refund`,
+ merchantService.makeInstanceBaseUrl(r.instance),
+ );
+ const resp = await axios.post(reqUrl.href, {
+ refund: r.amount,
+ reason: r.justification,
+ });
+ return {
+ talerRefundUri: resp.data.taler_refund_uri,
+ };
+ }
+
+ export async function createTippingReserve(
+ merchantService: MerchantServiceInterface,
+ instance: string,
+ req: CreateMerchantTippingReserveRequest,
+ ): Promise<CreateMerchantTippingReserveConfirmation> {
+ const reqUrl = new URL(
+ `private/reserves`,
+ merchantService.makeInstanceBaseUrl(instance),
+ );
+ const resp = await axios.post(reqUrl.href, req);
+ // FIXME: validate
+ return resp.data;
+ }
+
+ export async function queryTippingReserves(
+ merchantService: MerchantServiceInterface,
+ instance: string,
+ ): Promise<TippingReserveStatus> {
+ const reqUrl = new URL(
+ `private/reserves`,
+ merchantService.makeInstanceBaseUrl(instance),
+ );
+ const resp = await axios.get(reqUrl.href);
+ // FIXME: validate
+ return resp.data;
+ }
+
+ export async function giveTip(
+ merchantService: MerchantServiceInterface,
+ instance: string,
+ req: TipCreateRequest,
+ ): Promise<TipCreateConfirmation> {
+ const reqUrl = new URL(
+ `private/tips`,
+ merchantService.makeInstanceBaseUrl(instance),
+ );
+ const resp = await axios.post(reqUrl.href, req);
+ // FIXME: validate
+ return resp.data;
+ }
+}
+
+export interface CreateMerchantTippingReserveRequest {
+ // Amount that the merchant promises to put into the reserve
+ initial_balance: AmountString;
+
+ // Exchange the merchant intends to use for tipping
+ exchange_url: string;
+
+ // Desired wire method, for example "iban" or "x-taler-bank"
+ wire_method: string;
+}
+
+export interface CreateMerchantTippingReserveConfirmation {
+ // Public key identifying the reserve
+ reserve_pub: string;
+
+ // Wire account of the exchange where to transfer the funds
+ payto_uri: string;
+}
+
+export class MerchantService implements MerchantServiceInterface {
+ static fromExistingConfig(gc: GlobalTestState, name: string) {
+ const cfgFilename = gc.testDir + `/merchant-${name}.conf`;
+ const config = Configuration.load(cfgFilename);
+ const mc: MerchantConfig = {
+ currency: config.getString("taler", "currency").required(),
+ database: config.getString("merchantdb-postgres", "config").required(),
+ httpPort: config.getNumber("merchant", "port").required(),
+ name,
+ };
+ return new MerchantService(gc, mc, cfgFilename);
+ }
+
+ proc: ProcessWrapper | undefined;
+
+ constructor(
+ private globalState: GlobalTestState,
+ private merchantConfig: MerchantConfig,
+ private configFilename: string,
+ ) {}
+
+ private currentTimetravel: Duration | undefined;
+
+ private isRunning(): boolean {
+ return !!this.proc;
+ }
+
+ setTimetravel(t: Duration | undefined): void {
+ if (this.isRunning()) {
+ throw Error("can't set time travel while the exchange is running");
+ }
+ this.currentTimetravel = t;
+ }
+
+ private get timetravelArg(): string | undefined {
+ if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
+ // Convert to microseconds
+ return `--timetravel=+${this.currentTimetravel.d_ms * 1000}`;
+ }
+ return undefined;
+ }
+
+ /**
+ * Return an empty array if no time travel is set,
+ * and an array with the time travel command line argument
+ * otherwise.
+ */
+ private get timetravelArgArr(): string[] {
+ const tta = this.timetravelArg;
+ if (tta) {
+ return [tta];
+ }
+ return [];
+ }
+
+ get port(): number {
+ return this.merchantConfig.httpPort;
+ }
+
+ get name(): string {
+ return this.merchantConfig.name;
+ }
+
+ async stop(): Promise<void> {
+ const httpd = this.proc;
+ if (httpd) {
+ httpd.proc.kill("SIGTERM");
+ await httpd.wait();
+ this.proc = undefined;
+ }
+ }
+
+ async start(): Promise<void> {
+ await exec(`taler-merchant-dbinit -c "${this.configFilename}"`);
+
+ this.proc = this.globalState.spawnService(
+ "taler-merchant-httpd",
+ [
+ "taler-merchant-httpd",
+ "-LDEBUG",
+ "-c",
+ this.configFilename,
+ ...this.timetravelArgArr,
+ ],
+ `merchant-${this.merchantConfig.name}`,
+ );
+ }
+
+ static async create(
+ gc: GlobalTestState,
+ mc: MerchantConfig,
+ ): Promise<MerchantService> {
+ const config = new Configuration();
+ config.setString("taler", "currency", mc.currency);
+
+ const cfgFilename = gc.testDir + `/merchant-${mc.name}.conf`;
+ setTalerPaths(config, gc.testDir + "/talerhome");
+ config.setString("merchant", "serve", "tcp");
+ config.setString("merchant", "port", `${mc.httpPort}`);
+ config.setString(
+ "merchant",
+ "keyfile",
+ "${TALER_DATA_HOME}/merchant/merchant.priv",
+ );
+ config.setString("merchantdb-postgres", "config", mc.database);
+ config.write(cfgFilename);
+
+ return new MerchantService(gc, mc, cfgFilename);
+ }
+
+ addExchange(e: ExchangeServiceInterface): void {
+ const config = Configuration.load(this.configFilename);
+ config.setString(
+ `merchant-exchange-${e.name}`,
+ "exchange_base_url",
+ e.baseUrl,
+ );
+ config.setString(
+ `merchant-exchange-${e.name}`,
+ "currency",
+ this.merchantConfig.currency,
+ );
+ config.setString(`merchant-exchange-${e.name}`, "master_key", e.masterPub);
+ config.write(this.configFilename);
+ }
+
+ async addDefaultInstance(): Promise<void> {
+ return await this.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+ }
+
+ async addInstance(
+ instanceConfig: PartialMerchantInstanceConfig,
+ ): Promise<void> {
+ if (!this.proc) {
+ throw Error("merchant must be running to add instance");
+ }
+ logger.info("adding instance");
+ const url = `http://localhost:${this.merchantConfig.httpPort}/management/instances`;
+ const auth = instanceConfig.auth ?? { method: "external" };
+
+ const body: MerchantInstanceConfig = {
+ auth,
+ payto_uris: instanceConfig.paytoUris,
+ id: instanceConfig.id,
+ name: instanceConfig.name,
+ address: instanceConfig.address ?? {},
+ jurisdiction: instanceConfig.jurisdiction ?? {},
+ default_max_wire_fee:
+ instanceConfig.defaultMaxWireFee ??
+ `${this.merchantConfig.currency}:1.0`,
+ default_wire_fee_amortization:
+ instanceConfig.defaultWireFeeAmortization ?? 3,
+ default_max_deposit_fee:
+ instanceConfig.defaultMaxDepositFee ??
+ `${this.merchantConfig.currency}:1.0`,
+ default_wire_transfer_delay:
+ instanceConfig.defaultWireTransferDelay ??
+ Duration.toTalerProtocolDuration(
+ Duration.fromSpec({
+ days: 1,
+ }),
+ ),
+ default_pay_delay:
+ instanceConfig.defaultPayDelay ??
+ Duration.toTalerProtocolDuration(Duration.getForever()),
+ };
+ await axios.post(url, body);
+ }
+
+ makeInstanceBaseUrl(instanceName?: string): string {
+ if (instanceName === undefined || instanceName === "default") {
+ return `http://localhost:${this.merchantConfig.httpPort}/`;
+ } else {
+ return `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/`;
+ }
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `http://localhost:${this.merchantConfig.httpPort}/config`;
+ await pingProc(this.proc, url, `merchant (${this.merchantConfig.name})`);
+ }
+}
+
+export interface MerchantAuthConfiguration {
+ method: "external" | "token";
+ token?: string;
+}
+
+export interface PartialMerchantInstanceConfig {
+ auth?: MerchantAuthConfiguration;
+ id: string;
+ name: string;
+ paytoUris: string[];
+ address?: unknown;
+ jurisdiction?: unknown;
+ defaultMaxWireFee?: string;
+ defaultMaxDepositFee?: string;
+ defaultWireFeeAmortization?: number;
+ defaultWireTransferDelay?: TalerProtocolDuration;
+ defaultPayDelay?: TalerProtocolDuration;
+}
+
+export interface MerchantInstanceConfig {
+ auth: MerchantAuthConfiguration;
+ id: string;
+ name: string;
+ payto_uris: string[];
+ address: unknown;
+ jurisdiction: unknown;
+ default_max_wire_fee: string;
+ default_max_deposit_fee: string;
+ default_wire_fee_amortization: number;
+ default_wire_transfer_delay: TalerProtocolDuration;
+ default_pay_delay: TalerProtocolDuration;
+}
+
+type TestStatus = "pass" | "fail" | "skip";
+
+export interface TestRunResult {
+ /**
+ * Name of the test.
+ */
+ name: string;
+
+ /**
+ * How long did the test run?
+ */
+ timeSec: number;
+
+ status: TestStatus;
+
+ reason?: string;
+}
+
+export async function runTestWithState(
+ gc: GlobalTestState,
+ testMain: (t: GlobalTestState) => Promise<void>,
+ testName: string,
+ linger: boolean = false,
+): Promise<TestRunResult> {
+ const startMs = new Date().getTime();
+
+ const p = openPromise();
+ let status: TestStatus;
+
+ const handleSignal = (s: string) => {
+ logger.warn(
+ `**** received fatal process event, terminating test ${testName}`,
+ );
+ gc.shutdownSync();
+ process.exit(1);
+ };
+
+ process.on("SIGINT", handleSignal);
+ process.on("SIGTERM", handleSignal);
+ process.on("unhandledRejection", handleSignal);
+ process.on("uncaughtException", handleSignal);
+
+ try {
+ logger.info("running test in directory", gc.testDir);
+ await Promise.race([testMain(gc), p.promise]);
+ status = "pass";
+ if (linger) {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ terminal: true,
+ });
+ await new Promise<void>((resolve, reject) => {
+ rl.question("Press enter to shut down test.", () => {
+ logger.error("Requested shutdown");
+ resolve();
+ });
+ });
+ rl.close();
+ }
+ } catch (e) {
+ console.error("FATAL: test failed with exception", e);
+ if (e instanceof TalerError) {
+ console.error(`error detail: ${j2s(e.errorDetail)}`);
+ }
+ status = "fail";
+ } finally {
+ await gc.shutdown();
+ }
+ const afterMs = new Date().getTime();
+ return {
+ name: testName,
+ timeSec: (afterMs - startMs) / 1000,
+ status,
+ };
+}
+
+function shellWrap(s: string) {
+ return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'";
+}
+
+export interface WalletCliOpts {
+ cryptoWorkerType?: "sync" | "node-worker-thread";
+}
+
+export class WalletCli {
+ private currentTimetravel: Duration | undefined;
+ private _client: WalletCoreApiClient;
+
+ setTimetravel(d: Duration | undefined) {
+ this.currentTimetravel = d;
+ }
+
+ private get timetravelArg(): string | undefined {
+ if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
+ // Convert to microseconds
+ return `--timetravel=${this.currentTimetravel.d_ms * 1000}`;
+ }
+ return undefined;
+ }
+
+ constructor(
+ private globalTestState: GlobalTestState,
+ private name: string = "default",
+ cliOpts: WalletCliOpts = {},
+ ) {
+ const self = this;
+ this._client = {
+ async call(op: any, payload: any): Promise<any> {
+ logger.info(
+ `calling wallet with timetravel arg ${j2s(self.timetravelArg)}`,
+ );
+ const cryptoWorkerArg = cliOpts.cryptoWorkerType
+ ? `--crypto-worker=${cliOpts.cryptoWorkerType}`
+ : "";
+ const resp = await sh(
+ self.globalTestState,
+ `wallet-${self.name}`,
+ `taler-wallet-cli ${
+ self.timetravelArg ?? ""
+ } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${
+ self.dbfile
+ }' api '${op}' ${shellWrap(JSON.stringify(payload))}`,
+ );
+ logger.info("--- wallet core response ---");
+ logger.info(resp);
+ logger.info("--- end of response ---");
+ let ar: any;
+ try {
+ ar = JSON.parse(resp) as CoreApiResponse;
+ } catch (e) {
+ throw new Error("wallet CLI did not return a proper JSON response");
+ }
+ if (ar.type === "error") {
+ throw TalerError.fromUncheckedDetail(ar.error);
+ }
+ return ar.result;
+ },
+ };
+ }
+
+ get dbfile(): string {
+ return this.globalTestState.testDir + `/walletdb-${this.name}.json`;
+ }
+
+ deleteDatabase() {
+ fs.unlinkSync(this.dbfile);
+ }
+
+ private get timetravelArgArr(): string[] {
+ const tta = this.timetravelArg;
+ if (tta) {
+ return [tta];
+ }
+ return [];
+ }
+
+ get client(): WalletCoreApiClient {
+ return this._client;
+ }
+
+ async runUntilDone(args: { maxRetries?: number } = {}): Promise<void> {
+ await runCommand(
+ this.globalTestState,
+ `wallet-${this.name}`,
+ "taler-wallet-cli",
+ [
+ "--no-throttle",
+ ...this.timetravelArgArr,
+ "-LTRACE",
+ "--skip-defaults",
+ "--wallet-db",
+ this.dbfile,
+ "run-until-done",
+ ...(args.maxRetries ? ["--max-retries", `${args.maxRetries}`] : []),
+ ],
+ );
+ }
+
+ async runPending(): Promise<void> {
+ await runCommand(
+ this.globalTestState,
+ `wallet-${this.name}`,
+ "taler-wallet-cli",
+ [
+ "--no-throttle",
+ "--skip-defaults",
+ "-LTRACE",
+ ...this.timetravelArgArr,
+ "--wallet-db",
+ this.dbfile,
+ "advanced",
+ "run-pending",
+ ],
+ );
+ }
+}
+
+export function getRandomIban(salt: string | null = null): string {
+ function getBban(salt: string | null): string {
+ if (!salt) return Math.random().toString().substring(2, 6);
+ let hashed = hash(stringToBytes(salt));
+ let ret = "";
+ for (let i = 0; i < hashed.length; i++) {
+ ret += hashed[i].toString();
+ }
+ return ret.substring(0, 4);
+ }
+
+ let cc_no_check = "131400"; // == DE00
+ let bban = getBban(salt);
+ let check_digits = (
+ 98 -
+ (Number.parseInt(`${bban}${cc_no_check}`) % 97)
+ ).toString();
+ if (check_digits.length == 1) {
+ check_digits = `0${check_digits}`;
+ }
+ return `DE${check_digits}${bban}`;
+}
+
+export function getWireMethodForTest(): string {
+ if (useLibeufinBank) return "iban";
+ return "x-taler-bank";
+}
+
+/**
+ * Generate a payto address, whose authority depends
+ * on whether the banking is served by euFin or Pybank.
+ */
+export function getPayto(label: string): string {
+ if (useLibeufinBank)
+ return `payto://iban/SANDBOXX/${getRandomIban(
+ label,
+ )}?receiver-name=${label}`;
+ return `payto://x-taler-bank/localhost/${label}`;
+}
+
+function waitMs(ms: number): Promise<void> {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/packages/taler-harness/src/harness/helpers.ts b/packages/taler-harness/src/harness/helpers.ts
new file mode 100644
index 000000000..affaccd61
--- /dev/null
+++ b/packages/taler-harness/src/harness/helpers.ts
@@ -0,0 +1,444 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Helpers to create typical test environments.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports
+ */
+import {
+ AmountString,
+ ConfirmPayResultType,
+ MerchantContractTerms,
+ Duration,
+ PreparePayResultType,
+} from "@gnu-taler/taler-util";
+import {
+ BankAccessApi,
+ BankApi,
+ HarnessExchangeBankAccount,
+ WalletApiOperation,
+} from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "./denomStructures.js";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectedMerchantService,
+} from "./faultInjection.js";
+import {
+ BankService,
+ DbInfo,
+ ExchangeService,
+ ExchangeServiceInterface,
+ getPayto,
+ GlobalTestState,
+ MerchantPrivateApi,
+ MerchantService,
+ MerchantServiceInterface,
+ setupDb,
+ WalletCli,
+ WithAuthorization,
+} from "./harness.js";
+
+export interface SimpleTestEnvironment {
+ commonDb: DbInfo;
+ bank: BankService;
+ exchange: ExchangeService;
+ exchangeBankAccount: HarnessExchangeBankAccount;
+ merchant: MerchantService;
+ wallet: WalletCli;
+}
+
+export interface EnvOptions {
+ /**
+ * If provided, enable age restrictions with the specified age mask string.
+ */
+ ageMaskSpec?: string;
+
+ mixedAgeRestriction?: boolean;
+}
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ */
+export async function createSimpleTestkudosEnvironment(
+ t: GlobalTestState,
+ coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ opts: EnvOptions = {},
+): Promise<SimpleTestEnvironment> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const ageMaskSpec = opts.ageMaskSpec;
+
+ if (ageMaskSpec) {
+ exchange.enableAgeRestrictions(ageMaskSpec);
+ // Enable age restriction for all coins.
+ exchange.addCoinConfigList(
+ coinConfig.map((x) => ({
+ ...x,
+ name: `${x.name}-age`,
+ ageRestricted: true,
+ })),
+ );
+ // For mixed age restrictions, we also offer coins without age restrictions
+ if (opts.mixedAgeRestriction) {
+ exchange.addCoinConfigList(
+ coinConfig.map((x) => ({ ...x, ageRestricted: false })),
+ );
+ }
+ } else {
+ exchange.addCoinConfigList(coinConfig);
+ }
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstance({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ wallet,
+ bank,
+ exchangeBankAccount,
+ };
+}
+
+export interface FaultyMerchantTestEnvironment {
+ commonDb: DbInfo;
+ bank: BankService;
+ exchange: ExchangeService;
+ faultyExchange: FaultInjectedExchangeService;
+ exchangeBankAccount: HarnessExchangeBankAccount;
+ merchant: MerchantService;
+ faultyMerchant: FaultInjectedMerchantService;
+ wallet: WalletCli;
+}
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ */
+export async function createFaultInjectedMerchantTestkudosEnvironment(
+ t: GlobalTestState,
+): Promise<FaultyMerchantTestEnvironment> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const faultyMerchant = new FaultInjectedMerchantService(t, merchant, 9083);
+ const faultyExchange = new FaultInjectedExchangeService(t, exchange, 9081);
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(
+ faultyExchange,
+ exchangeBankAccount.accountPaytoUri,
+ );
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(faultyExchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ });
+
+ await merchant.addInstance({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ wallet,
+ bank,
+ exchangeBankAccount,
+ faultyMerchant,
+ faultyExchange,
+ };
+}
+
+/**
+ * Start withdrawing into the wallet.
+ *
+ * Only starts the operation, does not wait for it to finish.
+ */
+export async function startWithdrawViaBank(
+ t: GlobalTestState,
+ p: {
+ wallet: WalletCli;
+ bank: BankService;
+ exchange: ExchangeServiceInterface;
+ amount: AmountString;
+ restrictAge?: number;
+ },
+): Promise<void> {
+ const { wallet, bank, exchange, amount } = p;
+
+ const user = await BankApi.createRandomBankUser(bank);
+ const wop = await BankAccessApi.createWithdrawalOperation(bank, user, amount);
+
+ // Hand it to the wallet
+
+ await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ restrictAge: p.restrictAge,
+ });
+
+ await wallet.runPending();
+
+ // Withdraw (AKA select)
+
+ await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ restrictAge: p.restrictAge,
+ });
+
+ // Confirm it
+
+ await BankApi.confirmWithdrawalOperation(bank, user, wop);
+
+ // We do *not* call runPending / runUntilDone on the wallet here.
+ // Some tests rely on the final withdraw failing.
+}
+
+/**
+ * Withdraw balance.
+ */
+export async function withdrawViaBank(
+ t: GlobalTestState,
+ p: {
+ wallet: WalletCli;
+ bank: BankService;
+ exchange: ExchangeServiceInterface;
+ amount: AmountString;
+ restrictAge?: number;
+ },
+): Promise<void> {
+ const { wallet } = p;
+
+ await startWithdrawViaBank(t, p);
+
+ await wallet.runUntilDone();
+
+ // Check balance
+
+ await wallet.client.call(WalletApiOperation.GetBalances, {});
+}
+
+export async function applyTimeTravel(
+ timetravelDuration: Duration,
+ s: {
+ exchange?: ExchangeService;
+ merchant?: MerchantService;
+ wallet?: WalletCli;
+ },
+): Promise<void> {
+ if (s.exchange) {
+ await s.exchange.stop();
+ s.exchange.setTimetravel(timetravelDuration);
+ await s.exchange.start();
+ await s.exchange.pingUntilAvailable();
+ }
+
+ if (s.merchant) {
+ await s.merchant.stop();
+ s.merchant.setTimetravel(timetravelDuration);
+ await s.merchant.start();
+ await s.merchant.pingUntilAvailable();
+ }
+
+ if (s.wallet) {
+ s.wallet.setTimetravel(timetravelDuration);
+ }
+}
+
+/**
+ * Make a simple payment and check that it succeeded.
+ */
+export async function makeTestPayment(
+ t: GlobalTestState,
+ args: {
+ merchant: MerchantServiceInterface;
+ wallet: WalletCli;
+ order: Partial<MerchantContractTerms>;
+ instance?: string;
+ },
+ auth: WithAuthorization = {},
+): Promise<void> {
+ // Set up order.
+
+ const { wallet, merchant } = args;
+ const instance = args.instance ?? "default";
+
+ const orderResp = await MerchantPrivateApi.createOrder(
+ merchant,
+ instance,
+ {
+ order: args.order,
+ },
+ auth,
+ );
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderResp.order_id,
+ },
+ auth,
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const r2 = await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ proposalId: preparePayResult.proposalId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderResp.order_id,
+ instance,
+ },
+ auth,
+ );
+
+ t.assertTrue(orderStatus.order_status === "paid");
+}
diff --git a/packages/taler-harness/src/harness/libeufin-apis.ts b/packages/taler-harness/src/harness/libeufin-apis.ts
new file mode 100644
index 000000000..f55275927
--- /dev/null
+++ b/packages/taler-harness/src/harness/libeufin-apis.ts
@@ -0,0 +1,872 @@
+/**
+ * This file defines most of the API calls offered
+ * by Nexus and Sandbox. They don't have state,
+ * therefore got moved away from libeufin.ts where
+ * the services get actually started and managed.
+ */
+
+import axiosImp from "axios";
+const axios = axiosImp.default;
+import { Logger, URL } from "@gnu-taler/taler-util";
+
+export interface LibeufinSandboxServiceInterface {
+ baseUrl: string;
+}
+
+export interface LibeufinNexusServiceInterface {
+ baseUrl: string;
+}
+
+export interface CreateEbicsSubscriberRequest {
+ hostID: string;
+ userID: string;
+ partnerID: string;
+ systemID?: string;
+}
+
+export interface BankAccountInfo {
+ iban: string;
+ bic: string;
+ name: string;
+ label: string;
+}
+
+export interface CreateEbicsBankConnectionRequest {
+ name: string; // connection name.
+ ebicsURL: string;
+ hostID: string;
+ userID: string;
+ partnerID: string;
+ systemID?: string;
+}
+
+export interface UpdateNexusUserRequest {
+ newPassword: string;
+}
+
+export interface NexusAuth {
+ auth: {
+ username: string;
+ password: string;
+ };
+}
+
+export interface PostNexusTaskRequest {
+ name: string;
+ cronspec: string;
+ type: string; // fetch | submit
+ params:
+ | {
+ level: string; // report | statement | all
+ rangeType: string; // all | since-last | previous-days | latest
+ }
+ | {};
+}
+
+export interface CreateNexusUserRequest {
+ username: string;
+ password: string;
+}
+
+export interface PostNexusPermissionRequest {
+ action: "revoke" | "grant";
+ permission: {
+ subjectType: string;
+ subjectId: string;
+ resourceType: string;
+ resourceId: string;
+ permissionName: string;
+ };
+}
+
+export interface CreateAnastasisFacadeRequest {
+ name: string;
+ connectionName: string;
+ accountName: string;
+ currency: string;
+ reserveTransferLevel: "report" | "statement" | "notification";
+}
+
+export interface CreateTalerWireGatewayFacadeRequest {
+ name: string;
+ connectionName: string;
+ accountName: string;
+ currency: string;
+ reserveTransferLevel: "report" | "statement" | "notification";
+}
+
+export interface SandboxAccountTransactions {
+ payments: {
+ accountLabel: string;
+ creditorIban: string;
+ creditorBic?: string;
+ creditorName: string;
+ debtorIban: string;
+ debtorBic: string;
+ debtorName: string;
+ amount: string;
+ currency: string;
+ subject: string;
+ date: string;
+ creditDebitIndicator: "debit" | "credit";
+ accountServicerReference: string;
+ }[];
+}
+
+export interface DeleteBankConnectionRequest {
+ bankConnectionId: string;
+}
+
+export interface SimulateIncomingTransactionRequest {
+ debtorIban: string;
+ debtorBic: string;
+ debtorName: string;
+
+ /**
+ * Subject / unstructured remittance info.
+ */
+ subject: string;
+
+ /**
+ * Decimal amount without currency.
+ */
+ amount: string;
+}
+
+export interface CreateEbicsBankAccountRequest {
+ subscriber: {
+ hostID: string;
+ partnerID: string;
+ userID: string;
+ systemID?: string;
+ };
+ // IBAN
+ iban: string;
+ // BIC
+ bic: string;
+ // human name
+ name: string;
+ label: string;
+}
+
+export interface LibeufinSandboxAddIncomingRequest {
+ creditorIban: string;
+ creditorBic: string;
+ creditorName: string;
+ debtorIban: string;
+ debtorBic: string;
+ debtorName: string;
+ subject: string;
+ amount: string;
+ currency: string;
+ uid: string;
+ direction: string;
+}
+
+function getRandomString(): string {
+ return Math.random().toString(36).substring(2);
+}
+
+/**
+ * APIs spread accross Legacy and Access, it is therefore
+ * the "base URL" relative to which API every call addresses.
+ */
+export namespace LibeufinSandboxApi {
+ // Need Access API base URL.
+ export async function demobankAccountInfo(
+ username: string,
+ password: string,
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ accountLabel: string,
+ ) {
+ let url = new URL(
+ `accounts/${accountLabel}`,
+ libeufinSandboxService.baseUrl,
+ );
+ return await axios.get(url.href, {
+ auth: {
+ username: username,
+ password: password,
+ },
+ });
+ }
+
+ // Creates one bank account via the Access API.
+ // Need the /demobanks/$id/access-api as the base URL
+ export async function createDemobankAccount(
+ username: string,
+ password: string,
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ iban: string|null = null,
+ ) {
+ let url = new URL(
+ "testing/register",
+ libeufinSandboxService.baseUrl
+ );
+ await axios.post(url.href, {
+ username: username,
+ password: password,
+ iban: iban
+ });
+ }
+ // Need /demobanks/$id as the base URL
+ export async function createDemobankEbicsSubscriber(
+ req: CreateEbicsSubscriberRequest,
+ demobankAccountLabel: string,
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ username: string = "admin",
+ password: string = "secret",
+ ) {
+ // baseUrl should already be pointed to one demobank.
+ let url = new URL(
+ "ebics/subscribers",
+ libeufinSandboxService.baseUrl
+ );
+ await axios.post(
+ url.href,
+ {
+ userID: req.userID,
+ hostID: req.hostID,
+ partnerID: req.partnerID,
+ demobankAccountLabel: demobankAccountLabel,
+ },
+ {
+ auth: {
+ username: "admin",
+ password: "secret",
+ },
+ },
+ );
+ }
+
+ export async function rotateKeys(
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ hostID: string,
+ ) {
+ const baseUrl = libeufinSandboxService.baseUrl;
+ let url = new URL(`admin/ebics/hosts/${hostID}/rotate-keys`, baseUrl);
+ await axios.post(
+ url.href,
+ {},
+ {
+ auth: {
+ username: "admin",
+ password: "secret",
+ },
+ },
+ );
+ }
+ export async function createEbicsHost(
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ hostID: string,
+ ) {
+ const baseUrl = libeufinSandboxService.baseUrl;
+ let url = new URL("admin/ebics/hosts", baseUrl);
+ await axios.post(
+ url.href,
+ {
+ hostID,
+ ebicsVersion: "2.5",
+ },
+ {
+ auth: {
+ username: "admin",
+ password: "secret",
+ },
+ },
+ );
+ }
+
+ export async function createBankAccount(
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ req: BankAccountInfo,
+ ) {
+ const baseUrl = libeufinSandboxService.baseUrl;
+ let url = new URL(`admin/bank-accounts/${req.label}`, baseUrl);
+ await axios.post(url.href, req, {
+ auth: {
+ username: "admin",
+ password: "secret",
+ },
+ });
+ }
+
+ /**
+ * This function is useless. It creates a Ebics subscriber
+ * but never gives it a bank account. To be removed
+ */
+ export async function createEbicsSubscriber(
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ req: CreateEbicsSubscriberRequest,
+ ) {
+ const baseUrl = libeufinSandboxService.baseUrl;
+ let url = new URL("admin/ebics/subscribers", baseUrl);
+ await axios.post(url.href, req, {
+ auth: {
+ username: "admin",
+ password: "secret",
+ },
+ });
+ }
+
+ /**
+ * Create a new bank account and associate it to
+ * a existing EBICS subscriber.
+ */
+ export async function createEbicsBankAccount(
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ req: CreateEbicsBankAccountRequest,
+ ) {
+ const baseUrl = libeufinSandboxService.baseUrl;
+ let url = new URL("admin/ebics/bank-accounts", baseUrl);
+ await axios.post(url.href, req, {
+ auth: {
+ username: "admin",
+ password: "secret",
+ },
+ });
+ }
+
+ export async function simulateIncomingTransaction(
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ accountLabel: string,
+ req: SimulateIncomingTransactionRequest,
+ ) {
+ const baseUrl = libeufinSandboxService.baseUrl;
+ let url = new URL(
+ `admin/bank-accounts/${accountLabel}/simulate-incoming-transaction`,
+ baseUrl,
+ );
+ await axios.post(url.href, req, {
+ auth: {
+ username: "admin",
+ password: "secret",
+ },
+ });
+ }
+
+ export async function getAccountTransactions(
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ accountLabel: string,
+ ): Promise<SandboxAccountTransactions> {
+ const baseUrl = libeufinSandboxService.baseUrl;
+ let url = new URL(
+ `admin/bank-accounts/${accountLabel}/transactions`,
+ baseUrl,
+ );
+ const res = await axios.get(url.href, {
+ auth: {
+ username: "admin",
+ password: "secret",
+ },
+ });
+ return res.data as SandboxAccountTransactions;
+ }
+
+ export async function getCamt053(
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ accountLabel: string,
+ ): Promise<any> {
+ const baseUrl = libeufinSandboxService.baseUrl;
+ let url = new URL("admin/payments/camt", baseUrl);
+ return await axios.post(
+ url.href,
+ {
+ bankaccount: accountLabel,
+ type: 53,
+ },
+ {
+ auth: {
+ username: "admin",
+ password: "secret",
+ },
+ },
+ );
+ }
+
+ export async function getAccountInfoWithBalance(
+ libeufinSandboxService: LibeufinSandboxServiceInterface,
+ accountLabel: string,
+ ): Promise<any> {
+ const baseUrl = libeufinSandboxService.baseUrl;
+ let url = new URL(`admin/bank-accounts/${accountLabel}`, baseUrl);
+ return await axios.get(url.href, {
+ auth: {
+ username: "admin",
+ password: "secret",
+ },
+ });
+ }
+}
+
+export namespace LibeufinNexusApi {
+ export async function getAllConnections(
+ nexus: LibeufinNexusServiceInterface,
+ ): Promise<any> {
+ let url = new URL("bank-connections", nexus.baseUrl);
+ const res = await axios.get(url.href, {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ });
+ return res;
+ }
+
+ export async function deleteBankConnection(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ req: DeleteBankConnectionRequest,
+ ): Promise<any> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL("bank-connections/delete-connection", baseUrl);
+ return await axios.post(url.href, req, {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ });
+ }
+
+ export async function createEbicsBankConnection(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ req: CreateEbicsBankConnectionRequest,
+ ): Promise<void> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL("bank-connections", baseUrl);
+ await axios.post(
+ url.href,
+ {
+ source: "new",
+ type: "ebics",
+ name: req.name,
+ data: {
+ ebicsURL: req.ebicsURL,
+ hostID: req.hostID,
+ userID: req.userID,
+ partnerID: req.partnerID,
+ systemID: req.systemID,
+ },
+ },
+ {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ },
+ );
+ }
+
+ export async function getBankAccount(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ accountName: string,
+ ): Promise<any> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`bank-accounts/${accountName}`, baseUrl);
+ return await axios.get(url.href, {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ });
+ }
+
+ export async function submitInitiatedPayment(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ accountName: string,
+ paymentId: string,
+ ): Promise<void> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(
+ `bank-accounts/${accountName}/payment-initiations/${paymentId}/submit`,
+ baseUrl,
+ );
+ await axios.post(
+ url.href,
+ {},
+ {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ },
+ );
+ }
+
+ export async function fetchAccounts(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ connectionName: string,
+ ): Promise<void> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(
+ `bank-connections/${connectionName}/fetch-accounts`,
+ baseUrl,
+ );
+ await axios.post(
+ url.href,
+ {},
+ {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ },
+ );
+ }
+
+ export async function importConnectionAccount(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ connectionName: string,
+ offeredAccountId: string,
+ nexusBankAccountId: string,
+ ): Promise<void> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(
+ `bank-connections/${connectionName}/import-account`,
+ baseUrl,
+ );
+ await axios.post(
+ url.href,
+ {
+ offeredAccountId,
+ nexusBankAccountId,
+ },
+ {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ },
+ );
+ }
+
+ export async function connectBankConnection(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ connectionName: string,
+ ) {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`bank-connections/${connectionName}/connect`, baseUrl);
+ await axios.post(
+ url.href,
+ {},
+ {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ },
+ );
+ }
+
+ export async function getPaymentInitiations(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ accountName: string,
+ username: string = "admin",
+ password: string = "test",
+ ): Promise<void> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(
+ `/bank-accounts/${accountName}/payment-initiations`,
+ baseUrl,
+ );
+ let response = await axios.get(url.href, {
+ auth: {
+ username: username,
+ password: password,
+ },
+ });
+ console.log(
+ `Payment initiations of: ${accountName}`,
+ JSON.stringify(response.data, null, 2),
+ );
+ }
+
+ export async function getConfig(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ ): Promise<void> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`/config`, baseUrl);
+ let response = await axios.get(url.href);
+ }
+
+ // Uses the Anastasis API to get a list of transactions.
+ export async function getAnastasisTransactions(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ anastasisBaseUrl: string,
+ params: {}, // of the request: {delta: 5, ..}
+ username: string = "admin",
+ password: string = "test",
+ ): Promise<any> {
+ let url = new URL("history/incoming", anastasisBaseUrl);
+ let response = await axios.get(url.href, {
+ params: params,
+ auth: {
+ username: username,
+ password: password,
+ },
+ });
+ return response;
+ }
+
+ // FIXME: this function should return some structured
+ // object that represents a history.
+ export async function getAccountTransactions(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ accountName: string,
+ username: string = "admin",
+ password: string = "test",
+ ): Promise<any> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`/bank-accounts/${accountName}/transactions`, baseUrl);
+ let response = await axios.get(url.href, {
+ auth: {
+ username: username,
+ password: password,
+ },
+ });
+ return response;
+ }
+
+ export async function fetchTransactions(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ accountName: string,
+ rangeType: string = "all",
+ level: string = "report",
+ username: string = "admin",
+ password: string = "test",
+ ): Promise<any> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(
+ `/bank-accounts/${accountName}/fetch-transactions`,
+ baseUrl,
+ );
+ return await axios.post(
+ url.href,
+ {
+ rangeType: rangeType,
+ level: level,
+ },
+ {
+ auth: {
+ username: username,
+ password: password,
+ },
+ },
+ );
+ }
+
+ export async function changePassword(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ username: string,
+ req: UpdateNexusUserRequest,
+ auth: NexusAuth,
+ ) {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`/users/${username}/password`, baseUrl);
+ await axios.post(url.href, req, auth);
+ }
+
+ export async function getUser(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ auth: NexusAuth,
+ ): Promise<any> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`/user`, baseUrl);
+ return await axios.get(url.href, auth);
+ }
+
+ export async function createUser(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ req: CreateNexusUserRequest,
+ ) {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`/users`, baseUrl);
+ await axios.post(url.href, req, {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ });
+ }
+
+ export async function getAllPermissions(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ ): Promise<any> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`/permissions`, baseUrl);
+ return await axios.get(url.href, {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ });
+ }
+
+ export async function postPermission(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ req: PostNexusPermissionRequest,
+ ) {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`/permissions`, baseUrl);
+ await axios.post(url.href, req, {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ });
+ }
+
+ export async function getTasks(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ bankAccountName: string,
+ // When void, the request returns the list of all the
+ // tasks under this bank account.
+ taskName: string | void,
+ ): Promise<any> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`/bank-accounts/${bankAccountName}/schedule`, baseUrl);
+ if (taskName) url = new URL(taskName, `${url.href}/`);
+
+ // It's caller's responsibility to interpret the response.
+ return await axios.get(url.href, {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ });
+ }
+
+ export async function deleteTask(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ bankAccountName: string,
+ taskName: string,
+ ) {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(
+ `/bank-accounts/${bankAccountName}/schedule/${taskName}`,
+ baseUrl,
+ );
+ await axios.delete(url.href, {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ });
+ }
+
+ export async function postTask(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ bankAccountName: string,
+ req: PostNexusTaskRequest,
+ ): Promise<any> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`/bank-accounts/${bankAccountName}/schedule`, baseUrl);
+ return await axios.post(url.href, req, {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ });
+ }
+
+ export async function deleteFacade(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ facadeName: string,
+ ): Promise<any> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(`facades/${facadeName}`, baseUrl);
+ return await axios.delete(url.href, {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ });
+ }
+
+ export async function getAllFacades(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ ): Promise<any> {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL("facades", baseUrl);
+ return await axios.get(url.href, {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ });
+ }
+
+ export async function createAnastasisFacade(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ req: CreateAnastasisFacadeRequest,
+ ) {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL("facades", baseUrl);
+ await axios.post(
+ url.href,
+ {
+ name: req.name,
+ type: "anastasis",
+ config: {
+ bankAccount: req.accountName,
+ bankConnection: req.connectionName,
+ currency: req.currency,
+ reserveTransferLevel: req.reserveTransferLevel,
+ },
+ },
+ {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ },
+ );
+ }
+
+ export async function createTwgFacade(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ req: CreateTalerWireGatewayFacadeRequest,
+ ) {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL("facades", baseUrl);
+ await axios.post(
+ url.href,
+ {
+ name: req.name,
+ type: "taler-wire-gateway",
+ config: {
+ bankAccount: req.accountName,
+ bankConnection: req.connectionName,
+ currency: req.currency,
+ reserveTransferLevel: req.reserveTransferLevel,
+ },
+ },
+ {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ },
+ );
+ }
+
+ export async function submitAllPaymentInitiations(
+ libeufinNexusService: LibeufinNexusServiceInterface,
+ accountId: string,
+ ) {
+ const baseUrl = libeufinNexusService.baseUrl;
+ let url = new URL(
+ `/bank-accounts/${accountId}/submit-all-payment-initiations`,
+ baseUrl,
+ );
+ await axios.post(
+ url.href,
+ {},
+ {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ },
+ );
+ }
+}
diff --git a/packages/taler-harness/src/harness/libeufin.ts b/packages/taler-harness/src/harness/libeufin.ts
new file mode 100644
index 000000000..638c8ed90
--- /dev/null
+++ b/packages/taler-harness/src/harness/libeufin.ts
@@ -0,0 +1,910 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * This file defines euFin test logic that needs state
+ * and that depends on the main harness.ts. The other
+ * definitions - mainly helper functions to call RESTful
+ * APIs - moved to libeufin-apis.ts. That enables harness.ts
+ * to depend on such API calls, in contrast to the previous
+ * situation where harness.ts had to include this file causing
+ * a circular dependency. */
+
+/**
+ * Imports.
+ */
+import axios from "axios";
+import { URL, Logger } from "@gnu-taler/taler-util";
+import {
+ GlobalTestState,
+ DbInfo,
+ pingProc,
+ ProcessWrapper,
+ runCommand,
+ setupDb,
+ sh,
+ getRandomIban,
+} from "../harness/harness.js";
+import {
+ LibeufinSandboxApi,
+ LibeufinNexusApi,
+ CreateEbicsBankAccountRequest,
+ LibeufinSandboxServiceInterface,
+ CreateTalerWireGatewayFacadeRequest,
+ SimulateIncomingTransactionRequest,
+ SandboxAccountTransactions,
+ DeleteBankConnectionRequest,
+ CreateEbicsBankConnectionRequest,
+ UpdateNexusUserRequest,
+ NexusAuth,
+ CreateAnastasisFacadeRequest,
+ PostNexusTaskRequest,
+ PostNexusPermissionRequest,
+ CreateNexusUserRequest,
+} from "../harness/libeufin-apis.js";
+
+
+const logger = new Logger("libeufin.ts");
+
+export { LibeufinSandboxApi, LibeufinNexusApi };
+
+export interface LibeufinServices {
+ libeufinSandbox: LibeufinSandboxService;
+ libeufinNexus: LibeufinNexusService;
+ commonDb: DbInfo;
+}
+
+export interface LibeufinSandboxConfig {
+ httpPort: number;
+ databaseJdbcUri: string;
+}
+
+export interface LibeufinNexusConfig {
+ httpPort: number;
+ databaseJdbcUri: string;
+}
+
+interface LibeufinNexusMoneyMovement {
+ amount: string;
+ creditDebitIndicator: string;
+ details: {
+ debtor: {
+ name: string;
+ };
+ debtorAccount: {
+ iban: string;
+ };
+ debtorAgent: {
+ bic: string;
+ };
+ creditor: {
+ name: string;
+ };
+ creditorAccount: {
+ iban: string;
+ };
+ creditorAgent: {
+ bic: string;
+ };
+ endToEndId: string;
+ unstructuredRemittanceInformation: string;
+ };
+}
+
+interface LibeufinNexusBatches {
+ batchTransactions: Array<LibeufinNexusMoneyMovement>;
+}
+
+interface LibeufinNexusTransaction {
+ amount: string;
+ creditDebitIndicator: string;
+ status: string;
+ bankTransactionCode: string;
+ valueDate: string;
+ bookingDate: string;
+ accountServicerRef: string;
+ batches: Array<LibeufinNexusBatches>;
+}
+
+interface LibeufinNexusTransactions {
+ transactions: Array<LibeufinNexusTransaction>;
+}
+
+export interface LibeufinCliDetails {
+ nexusUrl: string;
+ sandboxUrl: string;
+ nexusDatabaseUri: string;
+ sandboxDatabaseUri: string;
+ nexusUser: LibeufinNexusUser;
+}
+
+export interface LibeufinEbicsSubscriberDetails {
+ hostId: string;
+ partnerId: string;
+ userId: string;
+}
+
+export interface LibeufinEbicsConnectionDetails {
+ subscriberDetails: LibeufinEbicsSubscriberDetails;
+ ebicsUrl: string;
+ connectionName: string;
+}
+
+export interface LibeufinBankAccountDetails {
+ currency: string;
+ iban: string;
+ bic: string;
+ personName: string;
+ accountName: string;
+}
+
+export interface LibeufinNexusUser {
+ username: string;
+ password: string;
+}
+
+export interface LibeufinBackupFileDetails {
+ passphrase: string;
+ outputFile: string;
+ connectionName: string;
+}
+
+export interface LibeufinKeyLetterDetails {
+ outputFile: string;
+ connectionName: string;
+}
+
+export interface LibeufinBankAccountImportDetails {
+ offeredBankAccountName: string;
+ nexusBankAccountName: string;
+ connectionName: string;
+}
+
+export interface LibeufinPreparedPaymentDetails {
+ creditorIban: string;
+ creditorBic: string;
+ creditorName: string;
+ subject: string;
+ amount: string;
+ currency: string;
+ nexusBankAccountName: string;
+}
+
+export class LibeufinSandboxService implements LibeufinSandboxServiceInterface {
+ static async create(
+ gc: GlobalTestState,
+ sandboxConfig: LibeufinSandboxConfig,
+ ): Promise<LibeufinSandboxService> {
+ return new LibeufinSandboxService(gc, sandboxConfig);
+ }
+
+ sandboxProc: ProcessWrapper | undefined;
+ globalTestState: GlobalTestState;
+
+ constructor(
+ gc: GlobalTestState,
+ private sandboxConfig: LibeufinSandboxConfig,
+ ) {
+ this.globalTestState = gc;
+ }
+
+ get baseUrl(): string {
+ return `http://localhost:${this.sandboxConfig.httpPort}/`;
+ }
+
+ async start(): Promise<void> {
+ await sh(
+ this.globalTestState,
+ "libeufin-sandbox-config",
+ "libeufin-sandbox config default",
+ {
+ ...process.env,
+ LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxConfig.databaseJdbcUri,
+ },
+ );
+
+ this.sandboxProc = this.globalTestState.spawnService(
+ "libeufin-sandbox",
+ ["serve", "--port", `${this.sandboxConfig.httpPort}`],
+ "libeufin-sandbox",
+ {
+ ...process.env,
+ LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxConfig.databaseJdbcUri,
+ LIBEUFIN_SANDBOX_ADMIN_PASSWORD: "secret",
+ },
+ );
+ }
+
+ async c53tick(): Promise<string> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-sandbox-c53tick",
+ "libeufin-sandbox camt053tick",
+ {
+ ...process.env,
+ LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxConfig.databaseJdbcUri,
+ },
+ );
+ return stdout;
+ }
+
+ async makeTransaction(
+ debit: string,
+ credit: string,
+ amount: string, // $currency:x.y
+ subject: string,
+ ): Promise<string> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-sandbox-maketransfer",
+ `libeufin-sandbox make-transaction --debit-account=${debit} --credit-account=${credit} ${amount} "${subject}"`,
+ {
+ ...process.env,
+ LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxConfig.databaseJdbcUri,
+ },
+ );
+ return stdout;
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = this.baseUrl;
+ await pingProc(this.sandboxProc, url, "libeufin-sandbox");
+ }
+}
+
+export class LibeufinNexusService {
+ static async create(
+ gc: GlobalTestState,
+ nexusConfig: LibeufinNexusConfig,
+ ): Promise<LibeufinNexusService> {
+ return new LibeufinNexusService(gc, nexusConfig);
+ }
+
+ nexusProc: ProcessWrapper | undefined;
+ globalTestState: GlobalTestState;
+
+ constructor(gc: GlobalTestState, private nexusConfig: LibeufinNexusConfig) {
+ this.globalTestState = gc;
+ }
+
+ get baseUrl(): string {
+ return `http://localhost:${this.nexusConfig.httpPort}/`;
+ }
+
+ async start(): Promise<void> {
+ await runCommand(
+ this.globalTestState,
+ "libeufin-nexus-superuser",
+ "libeufin-nexus",
+ ["superuser", "admin", "--password", "test"],
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusConfig.databaseJdbcUri,
+ },
+ );
+
+ this.nexusProc = this.globalTestState.spawnService(
+ "libeufin-nexus",
+ ["serve", "--port", `${this.nexusConfig.httpPort}`],
+ "libeufin-nexus",
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusConfig.databaseJdbcUri,
+ },
+ );
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `${this.baseUrl}config`;
+ await pingProc(this.nexusProc, url, "libeufin-nexus");
+ }
+
+ async createNexusSuperuser(details: LibeufinNexusUser): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-nexus",
+ `libeufin-nexus superuser ${details.username} --password=${details.password}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusConfig.databaseJdbcUri,
+ },
+ );
+ console.log(stdout);
+ }
+}
+
+export interface TwgAddIncomingRequest {
+ amount: string;
+ reserve_pub: string;
+ debit_account: string;
+}
+
+/**
+ * The bundle aims at minimizing the amount of input
+ * data that is required to initialize a new user + Ebics
+ * connection.
+ */
+export class NexusUserBundle {
+ userReq: CreateNexusUserRequest;
+ connReq: CreateEbicsBankConnectionRequest;
+ anastasisReq: CreateAnastasisFacadeRequest;
+ twgReq: CreateTalerWireGatewayFacadeRequest;
+ twgTransferPermission: PostNexusPermissionRequest;
+ twgHistoryPermission: PostNexusPermissionRequest;
+ twgAddIncomingPermission: PostNexusPermissionRequest;
+ localAccountName: string;
+ remoteAccountName: string;
+
+ constructor(salt: string, ebicsURL: string) {
+ this.userReq = {
+ username: `username-${salt}`,
+ password: `password-${salt}`,
+ };
+
+ this.connReq = {
+ name: `connection-${salt}`,
+ ebicsURL: ebicsURL,
+ hostID: `ebicshost,${salt}`,
+ partnerID: `ebicspartner,${salt}`,
+ userID: `ebicsuser,${salt}`,
+ };
+
+ this.twgReq = {
+ currency: "EUR",
+ name: `twg-${salt}`,
+ reserveTransferLevel: "report",
+ accountName: `local-account-${salt}`,
+ connectionName: `connection-${salt}`,
+ };
+ this.anastasisReq = {
+ currency: "EUR",
+ name: `anastasis-${salt}`,
+ reserveTransferLevel: "report",
+ accountName: `local-account-${salt}`,
+ connectionName: `connection-${salt}`,
+ };
+ this.remoteAccountName = `remote-account-${salt}`;
+ this.localAccountName = `local-account-${salt}`;
+ this.twgTransferPermission = {
+ action: "grant",
+ permission: {
+ subjectId: `username-${salt}`,
+ subjectType: "user",
+ resourceType: "facade",
+ resourceId: `twg-${salt}`,
+ permissionName: "facade.talerWireGateway.transfer",
+ },
+ };
+ this.twgHistoryPermission = {
+ action: "grant",
+ permission: {
+ subjectId: `username-${salt}`,
+ subjectType: "user",
+ resourceType: "facade",
+ resourceId: `twg-${salt}`,
+ permissionName: "facade.talerWireGateway.history",
+ },
+ };
+ }
+}
+
+/**
+ * The bundle aims at minimizing the amount of input
+ * data that is required to initialize a new Sandbox
+ * customer, associating their bank account with a Ebics
+ * subscriber.
+ */
+export class SandboxUserBundle {
+ ebicsBankAccount: CreateEbicsBankAccountRequest;
+ constructor(salt: string) {
+ this.ebicsBankAccount = {
+ bic: "BELADEBEXXX",
+ iban: getRandomIban(),
+ label: `remote-account-${salt}`,
+ name: `Taler Exchange: ${salt}`,
+ subscriber: {
+ hostID: `ebicshost,${salt}`,
+ partnerID: `ebicspartner,${salt}`,
+ userID: `ebicsuser,${salt}`,
+ },
+ };
+ }
+}
+
+export class LibeufinCli {
+ cliDetails: LibeufinCliDetails;
+ globalTestState: GlobalTestState;
+
+ constructor(gc: GlobalTestState, cd: LibeufinCliDetails) {
+ this.globalTestState = gc;
+ this.cliDetails = cd;
+ }
+
+ env(): any {
+ return {
+ ...process.env,
+ LIBEUFIN_SANDBOX_URL: this.cliDetails.sandboxUrl,
+ LIBEUFIN_SANDBOX_USERNAME: "admin",
+ LIBEUFIN_SANDBOX_PASSWORD: "secret",
+ };
+ }
+
+ async checkSandbox(): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-checksandbox",
+ "libeufin-cli sandbox check",
+ this.env(),
+ );
+ }
+
+ async registerBankCustomer(username: string, password: string): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-registercustomer",
+ "libeufin-cli sandbox demobank register --name='Test Customer'",
+ {
+ ...process.env,
+ LIBEUFIN_SANDBOX_URL: this.cliDetails.sandboxUrl + "/demobanks/default",
+ LIBEUFIN_SANDBOX_USERNAME: username,
+ LIBEUFIN_SANDBOX_PASSWORD: password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async createEbicsHost(hostId: string): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-createebicshost",
+ `libeufin-cli sandbox ebicshost create --host-id=${hostId}`,
+ this.env(),
+ );
+ console.log(stdout);
+ }
+
+ async createEbicsSubscriber(
+ details: LibeufinEbicsSubscriberDetails,
+ ): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-createebicssubscriber",
+ "libeufin-cli sandbox ebicssubscriber create" +
+ ` --host-id=${details.hostId}` +
+ ` --partner-id=${details.partnerId}` +
+ ` --user-id=${details.userId}`,
+ this.env(),
+ );
+ console.log(stdout);
+ }
+
+ async createEbicsBankAccount(
+ sd: LibeufinEbicsSubscriberDetails,
+ bankAccountDetails: LibeufinBankAccountDetails,
+ ): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-createebicsbankaccount",
+ "libeufin-cli sandbox ebicsbankaccount create" +
+ ` --iban=${bankAccountDetails.iban}` +
+ ` --bic=${bankAccountDetails.bic}` +
+ ` --person-name='${bankAccountDetails.personName}'` +
+ ` --account-name=${bankAccountDetails.accountName}` +
+ ` --ebics-host-id=${sd.hostId}` +
+ ` --ebics-partner-id=${sd.partnerId}` +
+ ` --ebics-user-id=${sd.userId}`,
+ this.env(),
+ );
+ console.log(stdout);
+ }
+
+ async generateTransactions(accountName: string): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-generatetransactions",
+ `libeufin-cli sandbox bankaccount generate-transactions ${accountName}`,
+ this.env(),
+ );
+ console.log(stdout);
+ }
+
+ async showSandboxTransactions(accountName: string): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-showsandboxtransactions",
+ `libeufin-cli sandbox bankaccount transactions ${accountName}`,
+ this.env(),
+ );
+ console.log(stdout);
+ }
+
+ async createEbicsConnection(
+ connectionDetails: LibeufinEbicsConnectionDetails,
+ ): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-createebicsconnection",
+ `libeufin-cli connections new-ebics-connection` +
+ ` --ebics-url=${connectionDetails.ebicsUrl}` +
+ ` --host-id=${connectionDetails.subscriberDetails.hostId}` +
+ ` --partner-id=${connectionDetails.subscriberDetails.partnerId}` +
+ ` --ebics-user-id=${connectionDetails.subscriberDetails.userId}` +
+ ` ${connectionDetails.connectionName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async createBackupFile(details: LibeufinBackupFileDetails): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-createbackupfile",
+ `libeufin-cli connections export-backup` +
+ ` --passphrase=${details.passphrase}` +
+ ` --output-file=${details.outputFile}` +
+ ` ${details.connectionName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async createKeyLetter(details: LibeufinKeyLetterDetails): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-createkeyletter",
+ `libeufin-cli connections get-key-letter` +
+ ` ${details.connectionName} ${details.outputFile}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async connect(connectionName: string): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-connect",
+ `libeufin-cli connections connect ${connectionName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async downloadBankAccounts(connectionName: string): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-downloadbankaccounts",
+ `libeufin-cli connections download-bank-accounts ${connectionName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async listOfferedBankAccounts(connectionName: string): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-listofferedbankaccounts",
+ `libeufin-cli connections list-offered-bank-accounts ${connectionName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async importBankAccount(
+ importDetails: LibeufinBankAccountImportDetails,
+ ): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-importbankaccount",
+ "libeufin-cli connections import-bank-account" +
+ ` --offered-account-id=${importDetails.offeredBankAccountName}` +
+ ` --nexus-bank-account-id=${importDetails.nexusBankAccountName}` +
+ ` ${importDetails.connectionName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async fetchTransactions(bankAccountName: string): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-fetchtransactions",
+ `libeufin-cli accounts fetch-transactions ${bankAccountName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async transactions(bankAccountName: string): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-transactions",
+ `libeufin-cli accounts transactions ${bankAccountName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async preparePayment(details: LibeufinPreparedPaymentDetails): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-preparepayment",
+ `libeufin-cli accounts prepare-payment` +
+ ` --creditor-iban=${details.creditorIban}` +
+ ` --creditor-bic=${details.creditorBic}` +
+ ` --creditor-name='${details.creditorName}'` +
+ ` --payment-subject='${details.subject}'` +
+ ` --payment-amount=${details.currency}:${details.amount}` +
+ ` ${details.nexusBankAccountName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async submitPayment(
+ details: LibeufinPreparedPaymentDetails,
+ paymentUuid: string,
+ ): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-submitpayments",
+ `libeufin-cli accounts submit-payments` +
+ ` --payment-uuid=${paymentUuid}` +
+ ` ${details.nexusBankAccountName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async newAnastasisFacade(req: NewAnastasisFacadeReq): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-new-anastasis-facade",
+ `libeufin-cli facades new-anastasis-facade` +
+ ` --currency ${req.currency}` +
+ ` --facade-name ${req.facadeName}` +
+ ` ${req.connectionName} ${req.accountName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async newTalerWireGatewayFacade(req: NewTalerWireGatewayReq): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-new-taler-wire-gateway-facade",
+ `libeufin-cli facades new-taler-wire-gateway-facade` +
+ ` --currency ${req.currency}` +
+ ` --facade-name ${req.facadeName}` +
+ ` ${req.connectionName} ${req.accountName}`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+
+ async listFacades(): Promise<void> {
+ const stdout = await sh(
+ this.globalTestState,
+ "libeufin-cli-facades-list",
+ `libeufin-cli facades list`,
+ {
+ ...process.env,
+ LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
+ LIBEUFIN_NEXUS_USERNAME: this.cliDetails.nexusUser.username,
+ LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.nexusUser.password,
+ },
+ );
+ console.log(stdout);
+ }
+}
+
+interface NewAnastasisFacadeReq {
+ facadeName: string;
+ connectionName: string;
+ accountName: string;
+ currency: string;
+}
+
+interface NewTalerWireGatewayReq {
+ facadeName: string;
+ connectionName: string;
+ accountName: string;
+ currency: string;
+}
+
+/**
+ * Launch Nexus and Sandbox AND creates users / facades / bank accounts /
+ * .. all that's required to start making bank traffic.
+ */
+export async function launchLibeufinServices(
+ t: GlobalTestState,
+ nexusUserBundle: NexusUserBundle[],
+ sandboxUserBundle: SandboxUserBundle[] = [],
+ withFacades: string[] = [], // takes only "twg" and/or "anastasis"
+): Promise<LibeufinServices> {
+ const db = await setupDb(t);
+
+ const libeufinSandbox = await LibeufinSandboxService.create(t, {
+ httpPort: 5010,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
+ });
+
+ await libeufinSandbox.start();
+ await libeufinSandbox.pingUntilAvailable();
+
+ const libeufinNexus = await LibeufinNexusService.create(t, {
+ httpPort: 5011,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
+ });
+
+ await libeufinNexus.start();
+ await libeufinNexus.pingUntilAvailable();
+ console.log("Libeufin services launched!");
+
+ for (let sb of sandboxUserBundle) {
+ await LibeufinSandboxApi.createEbicsHost(
+ libeufinSandbox,
+ sb.ebicsBankAccount.subscriber.hostID,
+ );
+ await LibeufinSandboxApi.createEbicsSubscriber(
+ libeufinSandbox,
+ sb.ebicsBankAccount.subscriber,
+ );
+ await LibeufinSandboxApi.createDemobankAccount(
+ sb.ebicsBankAccount.label,
+ "password-unused",
+ { baseUrl: libeufinSandbox.baseUrl + "/demobanks/default/access-api/" }
+ );
+ await LibeufinSandboxApi.createDemobankEbicsSubscriber(
+ sb.ebicsBankAccount.subscriber,
+ sb.ebicsBankAccount.label,
+ { baseUrl: libeufinSandbox.baseUrl + "/demobanks/default/" }
+ );
+ }
+ console.log("Sandbox user(s) / account(s) / subscriber(s): created");
+
+ for (let nb of nexusUserBundle) {
+ await LibeufinNexusApi.createEbicsBankConnection(libeufinNexus, nb.connReq);
+ await LibeufinNexusApi.connectBankConnection(
+ libeufinNexus,
+ nb.connReq.name,
+ );
+ await LibeufinNexusApi.fetchAccounts(libeufinNexus, nb.connReq.name);
+ await LibeufinNexusApi.importConnectionAccount(
+ libeufinNexus,
+ nb.connReq.name,
+ nb.remoteAccountName,
+ nb.localAccountName,
+ );
+ await LibeufinNexusApi.createUser(libeufinNexus, nb.userReq);
+ for (let facade of withFacades) {
+ switch (facade) {
+ case "twg":
+ await LibeufinNexusApi.createTwgFacade(libeufinNexus, nb.twgReq);
+ await LibeufinNexusApi.postPermission(
+ libeufinNexus,
+ nb.twgTransferPermission,
+ );
+ await LibeufinNexusApi.postPermission(
+ libeufinNexus,
+ nb.twgHistoryPermission,
+ );
+ break;
+ case "anastasis":
+ await LibeufinNexusApi.createAnastasisFacade(
+ libeufinNexus,
+ nb.anastasisReq,
+ );
+ }
+ }
+ }
+ console.log(
+ "Nexus user(s) / connection(s) / facade(s) / permission(s): created",
+ );
+
+ return {
+ commonDb: db,
+ libeufinNexus: libeufinNexus,
+ libeufinSandbox: libeufinSandbox,
+ };
+}
+
+/**
+ * Helper function that searches a payment among
+ * a list, as returned by Nexus. The key is just
+ * the payment subject.
+ */
+export function findNexusPayment(
+ key: string,
+ payments: LibeufinNexusTransactions,
+): LibeufinNexusMoneyMovement | void {
+ let transactions = payments["transactions"];
+ for (let i = 0; i < transactions.length; i++) {
+ let batches = transactions[i]["batches"];
+ for (let y = 0; y < batches.length; y++) {
+ let movements = batches[y]["batchTransactions"];
+ for (let z = 0; z < movements.length; z++) {
+ let movement = movements[z];
+ if (movement["details"]["unstructuredRemittanceInformation"] == key)
+ return movement;
+ }
+ }
+ }
+}
diff --git a/packages/taler-harness/src/harness/merchantApiTypes.ts b/packages/taler-harness/src/harness/merchantApiTypes.ts
new file mode 100644
index 000000000..2a59b0160
--- /dev/null
+++ b/packages/taler-harness/src/harness/merchantApiTypes.ts
@@ -0,0 +1,337 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Test harness for various GNU Taler components.
+ * Also provides a fault-injection proxy.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ MerchantContractTerms,
+ Duration,
+ Codec,
+ buildCodecForObject,
+ codecForString,
+ codecOptional,
+ codecForConstString,
+ codecForBoolean,
+ codecForNumber,
+ codecForMerchantContractTerms,
+ codecForAny,
+ buildCodecForUnion,
+ AmountString,
+ AbsoluteTime,
+ CoinPublicKeyString,
+ EddsaPublicKeyString,
+ codecForAmountString,
+ TalerProtocolDuration,
+ codecForTimestamp,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+
+export interface PostOrderRequest {
+ // The order must at least contain the minimal
+ // order detail, but can override all
+ order: Partial<MerchantContractTerms>;
+
+ // if set, the backend will then set the refund deadline to the current
+ // time plus the specified delay.
+ refund_delay?: TalerProtocolDuration;
+
+ // specifies the payment target preferred by the client. Can be used
+ // to select among the various (active) wire methods supported by the instance.
+ payment_target?: string;
+
+ // FIXME: some fields are missing
+
+ // Should a token for claiming the order be generated?
+ // False can make sense if the ORDER_ID is sufficiently
+ // high entropy to prevent adversarial claims (like it is
+ // if the backend auto-generates one). Default is 'true'.
+ create_token?: boolean;
+}
+
+export type ClaimToken = string;
+
+export interface PostOrderResponse {
+ order_id: string;
+ token?: ClaimToken;
+}
+
+export const codecForPostOrderResponse = (): Codec<PostOrderResponse> =>
+ buildCodecForObject<PostOrderResponse>()
+ .property("order_id", codecForString())
+ .property("token", codecOptional(codecForString()))
+ .build("PostOrderResponse");
+
+
+export const codecForRefundDetails = (): Codec<RefundDetails> =>
+ buildCodecForObject<RefundDetails>()
+ .property("reason", codecForString())
+ .property("pending", codecForBoolean())
+ .property("amount", codecForString())
+ .property("timestamp", codecForTimestamp)
+ .build("PostOrderResponse");
+
+export const codecForCheckPaymentPaidResponse =
+ (): Codec<CheckPaymentPaidResponse> =>
+ buildCodecForObject<CheckPaymentPaidResponse>()
+ .property("order_status_url", codecForString())
+ .property("order_status", codecForConstString("paid"))
+ .property("refunded", codecForBoolean())
+ .property("wired", codecForBoolean())
+ .property("deposit_total", codecForAmountString())
+ .property("exchange_ec", codecForNumber())
+ .property("exchange_hc", codecForNumber())
+ .property("refund_amount", codecForAmountString())
+ .property("contract_terms", codecForMerchantContractTerms())
+ // FIXME: specify
+ .property("wire_details", codecForAny())
+ .property("wire_reports", codecForAny())
+ .property("refund_details", codecForAny())
+ .build("CheckPaymentPaidResponse");
+
+export const codecForCheckPaymentUnpaidResponse =
+ (): Codec<CheckPaymentUnpaidResponse> =>
+ buildCodecForObject<CheckPaymentUnpaidResponse>()
+ .property("order_status", codecForConstString("unpaid"))
+ .property("taler_pay_uri", codecForString())
+ .property("order_status_url", codecForString())
+ .property("already_paid_order_id", codecOptional(codecForString()))
+ .build("CheckPaymentPaidResponse");
+
+export const codecForCheckPaymentClaimedResponse =
+ (): Codec<CheckPaymentClaimedResponse> =>
+ buildCodecForObject<CheckPaymentClaimedResponse>()
+ .property("order_status", codecForConstString("claimed"))
+ .property("contract_terms", codecForMerchantContractTerms())
+ .build("CheckPaymentClaimedResponse");
+
+export const codecForMerchantOrderPrivateStatusResponse =
+ (): Codec<MerchantOrderPrivateStatusResponse> =>
+ buildCodecForUnion<MerchantOrderPrivateStatusResponse>()
+ .discriminateOn("order_status")
+ .alternative("paid", codecForCheckPaymentPaidResponse())
+ .alternative("unpaid", codecForCheckPaymentUnpaidResponse())
+ .alternative("claimed", codecForCheckPaymentClaimedResponse())
+ .build("MerchantOrderPrivateStatusResponse");
+
+export type MerchantOrderPrivateStatusResponse =
+ | CheckPaymentPaidResponse
+ | CheckPaymentUnpaidResponse
+ | CheckPaymentClaimedResponse;
+
+export interface CheckPaymentClaimedResponse {
+ // Wallet claimed the order, but didn't pay yet.
+ order_status: "claimed";
+
+ contract_terms: MerchantContractTerms;
+}
+
+export interface CheckPaymentPaidResponse {
+ // did the customer pay for this contract
+ order_status: "paid";
+
+ // Was the payment refunded (even partially)
+ refunded: boolean;
+
+ // Did the exchange wire us the funds
+ wired: boolean;
+
+ // Total amount the exchange deposited into our bank account
+ // for this contract, excluding fees.
+ deposit_total: AmountString;
+
+ // Numeric error code indicating errors the exchange
+ // encountered tracking the wire transfer for this purchase (before
+ // we even got to specific coin issues).
+ // 0 if there were no issues.
+ exchange_ec: number;
+
+ // HTTP status code returned by the exchange when we asked for
+ // information to track the wire transfer for this purchase.
+ // 0 if there were no issues.
+ exchange_hc: number;
+
+ // Total amount that was refunded, 0 if refunded is false.
+ refund_amount: AmountString;
+
+ // Contract terms
+ contract_terms: MerchantContractTerms;
+
+ // Ihe wire transfer status from the exchange for this order if available, otherwise empty array
+ wire_details: TransactionWireTransfer[];
+
+ // Reports about trouble obtaining wire transfer details, empty array if no trouble were encountered.
+ wire_reports: TransactionWireReport[];
+
+ // The refund details for this order. One entry per
+ // refunded coin; empty array if there are no refunds.
+ refund_details: RefundDetails[];
+
+ order_status_url: string;
+}
+
+export interface CheckPaymentUnpaidResponse {
+ order_status: "unpaid";
+
+ // URI that the wallet must process to complete the payment.
+ taler_pay_uri: string;
+
+ order_status_url: string;
+
+ // Alternative order ID which was paid for already in the same session.
+ // Only given if the same product was purchased before in the same session.
+ already_paid_order_id?: string;
+
+ // We do we NOT return the contract terms here because they may not
+ // exist in case the wallet did not yet claim them.
+}
+
+export interface RefundDetails {
+ // Reason given for the refund
+ reason: string;
+
+ // when was the refund approved
+ timestamp: TalerProtocolTimestamp;
+
+ // has not been taken yet
+ pending: boolean;
+
+ // Total amount that was refunded (minus a refund fee).
+ amount: AmountString;
+}
+
+export interface TransactionWireTransfer {
+ // Responsible exchange
+ exchange_url: string;
+
+ // 32-byte wire transfer identifier
+ wtid: string;
+
+ // execution time of the wire transfer
+ execution_time: AbsoluteTime;
+
+ // Total amount that has been wire transferred
+ // to the merchant
+ amount: AmountString;
+
+ // Was this transfer confirmed by the merchant via the
+ // POST /transfers API, or is it merely claimed by the exchange?
+ confirmed: boolean;
+}
+
+export interface TransactionWireReport {
+ // Numerical error code
+ code: number;
+
+ // Human-readable error description
+ hint: string;
+
+ // Numerical error code from the exchange.
+ exchange_ec: number;
+
+ // HTTP status code received from the exchange.
+ exchange_hc: number;
+
+ // Public key of the coin for which we got the exchange error.
+ coin_pub: CoinPublicKeyString;
+}
+
+export interface TippingReserveStatus {
+ // Array of all known reserves (possibly empty!)
+ reserves: ReserveStatusEntry[];
+}
+
+export interface ReserveStatusEntry {
+ // Public key of the reserve
+ reserve_pub: string;
+
+ // Timestamp when it was established
+ creation_time: AbsoluteTime;
+
+ // Timestamp when it expires
+ expiration_time: AbsoluteTime;
+
+ // Initial amount as per reserve creation call
+ merchant_initial_amount: AmountString;
+
+ // Initial amount as per exchange, 0 if exchange did
+ // not confirm reserve creation yet.
+ exchange_initial_amount: AmountString;
+
+ // Amount picked up so far.
+ pickup_amount: AmountString;
+
+ // Amount approved for tips that exceeds the pickup_amount.
+ committed_amount: AmountString;
+
+ // Is this reserve active (false if it was deleted but not purged)
+ active: boolean;
+}
+
+export interface TipCreateConfirmation {
+ // Unique tip identifier for the tip that was created.
+ tip_id: string;
+
+ // taler://tip URI for the tip
+ taler_tip_uri: string;
+
+ // URL that will directly trigger processing
+ // the tip when the browser is redirected to it
+ tip_status_url: string;
+
+ // when does the tip expire
+ tip_expiration: AbsoluteTime;
+}
+
+export interface TipCreateRequest {
+ // Amount that the customer should be tipped
+ amount: AmountString;
+
+ // Justification for giving the tip
+ justification: string;
+
+ // URL that the user should be directed to after tipping,
+ // will be included in the tip_token.
+ next_url: string;
+}
+
+export interface MerchantInstancesResponse {
+ // List of instances that are present in the backend (see Instance)
+ instances: MerchantInstanceDetail[];
+}
+
+export interface MerchantInstanceDetail {
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ // Merchant instance this response is about ($INSTANCE)
+ id: string;
+
+ // Public key of the merchant/instance, in Crockford Base32 encoding.
+ merchant_pub: EddsaPublicKeyString;
+
+ // List of the payment targets supported by this instance. Clients can
+ // specify the desired payment target in /order requests. Note that
+ // front-ends do not have to support wallets selecting payment targets.
+ payment_targets: string[];
+}
diff --git a/packages/taler-harness/src/harness/sync.ts b/packages/taler-harness/src/harness/sync.ts
new file mode 100644
index 000000000..a9e8de412
--- /dev/null
+++ b/packages/taler-harness/src/harness/sync.ts
@@ -0,0 +1,119 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { URL } from "@gnu-taler/taler-util";
+import * as fs from "fs";
+import * as util from "util";
+import {
+ GlobalTestState,
+ pingProc,
+ ProcessWrapper,
+} from "../harness/harness.js";
+import { Configuration } from "@gnu-taler/taler-util";
+import * as child_process from "child_process";
+
+const exec = util.promisify(child_process.exec);
+
+export interface SyncConfig {
+ /**
+ * Human-readable name used in the test harness logs.
+ */
+ name: string;
+
+ httpPort: number;
+
+ /**
+ * Database connection string (only postgres is supported).
+ */
+ database: string;
+
+ annualFee: string;
+
+ currency: string;
+
+ uploadLimitMb: number;
+
+ /**
+ * Fulfillment URL used for contract terms related to
+ * sync.
+ */
+ fulfillmentUrl: string;
+
+ paymentBackendUrl: string;
+}
+
+function setSyncPaths(config: Configuration, home: string) {
+ config.setString("paths", "sync_home", home);
+ // We need to make sure that the path of taler_runtime_dir isn't too long,
+ // as it contains unix domain sockets (108 character limit).
+ const runDir = fs.mkdtempSync("/tmp/taler-test-");
+ config.setString("paths", "sync_runtime_dir", runDir);
+ config.setString("paths", "sync_data_home", "$SYNC_HOME/.local/share/sync/");
+ config.setString("paths", "sync_config_home", "$SYNC_HOME/.config/sync/");
+ config.setString("paths", "sync_cache_home", "$SYNC_HOME/.config/sync/");
+}
+
+export class SyncService {
+ static async create(
+ gc: GlobalTestState,
+ sc: SyncConfig,
+ ): Promise<SyncService> {
+ const config = new Configuration();
+
+ const cfgFilename = gc.testDir + `/sync-${sc.name}.conf`;
+ setSyncPaths(config, gc.testDir + "/synchome");
+ config.setString("taler", "currency", sc.currency);
+ config.setString("sync", "serve", "tcp");
+ config.setString("sync", "port", `${sc.httpPort}`);
+ config.setString("sync", "db", "postgres");
+ config.setString("syncdb-postgres", "config", sc.database);
+ config.setString("sync", "payment_backend_url", sc.paymentBackendUrl);
+ config.setString("sync", "upload_limit_mb", `${sc.uploadLimitMb}`);
+ config.write(cfgFilename);
+
+ return new SyncService(gc, sc, cfgFilename);
+ }
+
+ proc: ProcessWrapper | undefined;
+
+ get baseUrl(): string {
+ return `http://localhost:${this.syncConfig.httpPort}/`;
+ }
+
+ async start(): Promise<void> {
+ await exec(`sync-dbinit -c "${this.configFilename}"`);
+
+ this.proc = this.globalState.spawnService(
+ "sync-httpd",
+ ["-LDEBUG", "-c", this.configFilename],
+ `sync-${this.syncConfig.name}`,
+ );
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = new URL("config", this.baseUrl).href;
+ await pingProc(this.proc, url, "sync");
+ }
+
+ constructor(
+ private globalState: GlobalTestState,
+ private syncConfig: SyncConfig,
+ private configFilename: string,
+ ) { }
+}
diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts
new file mode 100644
index 000000000..632bd5877
--- /dev/null
+++ b/packages/taler-harness/src/index.ts
@@ -0,0 +1,338 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { deepStrictEqual } from "assert";
+import fs from "fs";
+import os from "os";
+import path from "path";
+import {
+ Amounts,
+ clk,
+ Configuration,
+ decodeCrock,
+ Logger,
+ rsaBlind,
+ setGlobalLogLevelFromString,
+} from "@gnu-taler/taler-util";
+import { runBench1 } from "./bench1.js";
+import { runBench2 } from "./bench2.js";
+import { runBench3 } from "./bench3.js";
+import { runEnv1 } from "./env1.js";
+import { GlobalTestState, runTestWithState } from "./harness/harness.js";
+import { getTestInfo, runTests } from "./integrationtests/testrunner.js";
+import { lintExchangeDeployment } from "./lint.js";
+import { runEnvFull } from "./env-full.js";
+
+const logger = new Logger("taler-harness:index.ts");
+
+process.on("unhandledRejection", (error: any) => {
+ logger.error("unhandledRejection", error.message);
+ logger.error("stack", error.stack);
+ process.exit(2);
+});
+
+declare const __VERSION__: string;
+function printVersion(): void {
+ console.log(__VERSION__);
+ process.exit(0);
+}
+
+export const testingCli = clk
+ .program("testing", {
+ help: "Command line interface for the GNU Taler test/deployment harness.",
+ })
+ .maybeOption("log", ["-L", "--log"], clk.STRING, {
+ help: "configure log level (NONE, ..., TRACE)",
+ onPresentHandler: (x) => {
+ setGlobalLogLevelFromString(x);
+ },
+ })
+ .flag("version", ["-v", "--version"], {
+ onPresentHandler: printVersion,
+ })
+ .flag("verbose", ["-V", "--verbose"], {
+ help: "Enable verbose output.",
+ });
+
+const advancedCli = testingCli.subcommand("advancedArgs", "advanced", {
+ help: "Subcommands for advanced operations (only use if you know what you're doing!).",
+});
+
+advancedCli
+ .subcommand("bench1", "bench1", {
+ help: "Run the 'bench1' benchmark",
+ })
+ .requiredOption("configJson", ["--config-json"], clk.STRING)
+ .action(async (args) => {
+ let config: any;
+ try {
+ config = JSON.parse(args.bench1.configJson);
+ } catch (e) {
+ console.log("Could not parse config JSON");
+ }
+ await runBench1(config);
+ });
+
+advancedCli
+ .subcommand("bench2", "bench2", {
+ help: "Run the 'bench2' benchmark",
+ })
+ .requiredOption("configJson", ["--config-json"], clk.STRING)
+ .action(async (args) => {
+ let config: any;
+ try {
+ config = JSON.parse(args.bench2.configJson);
+ } catch (e) {
+ console.log("Could not parse config JSON");
+ }
+ await runBench2(config);
+ });
+
+advancedCli
+ .subcommand("bench3", "bench3", {
+ help: "Run the 'bench3' benchmark",
+ })
+ .requiredOption("configJson", ["--config-json"], clk.STRING)
+ .action(async (args) => {
+ let config: any;
+ try {
+ config = JSON.parse(args.bench3.configJson);
+ } catch (e) {
+ console.log("Could not parse config JSON");
+ }
+ await runBench3(config);
+ });
+
+advancedCli
+ .subcommand("envFull", "env-full", {
+ help: "Run a test environment for bench1",
+ })
+ .action(async (args) => {
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-env-full-"));
+ const testState = new GlobalTestState({
+ testDir,
+ });
+ await runTestWithState(testState, runEnvFull, "env-full", true);
+ });
+
+advancedCli
+ .subcommand("env1", "env1", {
+ help: "Run a test environment for bench1",
+ })
+ .action(async (args) => {
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-env1-"));
+ const testState = new GlobalTestState({
+ testDir,
+ });
+ await runTestWithState(testState, runEnv1, "env1", true);
+ });
+
+const deploymentCli = testingCli.subcommand("deploymentArgs", "deployment", {
+ help: "Subcommands for handling GNU Taler deployments.",
+});
+
+deploymentCli
+ .subcommand("lintExchange", "lint-exchange", {
+ help: "Run checks on the exchange deployment.",
+ })
+ .flag("cont", ["--continue"], {
+ help: "Continue after errors if possible",
+ })
+ .flag("debug", ["--debug"], {
+ help: "Output extra debug info",
+ })
+ .action(async (args) => {
+ await lintExchangeDeployment(
+ args.lintExchange.debug,
+ args.lintExchange.cont,
+ );
+ });
+
+deploymentCli
+ .subcommand("coincfg", "gen-coin-config", {
+ help: "Generate a coin/denomination configuration for the exchange.",
+ })
+ .requiredOption("minAmount", ["--min-amount"], clk.STRING, {
+ help: "Smallest denomination",
+ })
+ .requiredOption("maxAmount", ["--max-amount"], clk.STRING, {
+ help: "Largest denomination",
+ })
+ .action(async (args) => {
+ let out = "";
+
+ const stamp = Math.floor(new Date().getTime() / 1000);
+
+ const min = Amounts.parseOrThrow(args.coincfg.minAmount);
+ const max = Amounts.parseOrThrow(args.coincfg.maxAmount);
+ if (min.currency != max.currency) {
+ console.error("currency mismatch");
+ process.exit(1);
+ }
+ const currency = min.currency;
+ let x = min;
+ let n = 1;
+
+ out += "# Coin configuration for the exchange.\n";
+ out += '# Should be placed in "/etc/taler/conf.d/exchange-coins.conf".\n';
+ out += "\n";
+
+ while (Amounts.cmp(x, max) < 0) {
+ out += `[COIN-${currency}-n${n}-t${stamp}]\n`;
+ out += `VALUE = ${Amounts.stringify(x)}\n`;
+ out += `DURATION_WITHDRAW = 7 days\n`;
+ out += `DURATION_SPEND = 2 years\n`;
+ out += `DURATION_LEGAL = 6 years\n`;
+ out += `FEE_WITHDRAW = ${currency}:0\n`;
+ out += `FEE_DEPOSIT = ${Amounts.stringify(min)}\n`;
+ out += `FEE_REFRESH = ${currency}:0\n`;
+ out += `FEE_REFUND = ${currency}:0\n`;
+ out += `RSA_KEYSIZE = 2048\n`;
+ out += "\n";
+ x = Amounts.add(x, x).amount;
+ n++;
+ }
+
+ console.log(out);
+ });
+
+const deploymentConfigCli = deploymentCli.subcommand("configArgs", "config", {
+ help: "Subcommands the Taler configuration.",
+});
+
+deploymentConfigCli
+ .subcommand("show", "show")
+ .flag("diagnostics", ["-d", "--diagnostics"])
+ .maybeArgument("cfgfile", clk.STRING, {})
+ .action(async (args) => {
+ const cfg = Configuration.load(args.show.cfgfile);
+ console.log(
+ cfg.stringify({
+ diagnostics: args.show.diagnostics,
+ }),
+ );
+ });
+
+
+testingCli.subcommand("logtest", "logtest").action(async (args) => {
+ logger.trace("This is a trace message.");
+ logger.info("This is an info message.");
+ logger.warn("This is an warning message.");
+ logger.error("This is an error message.");
+});
+
+testingCli
+ .subcommand("listIntegrationtests", "list-integrationtests")
+ .action(async (args) => {
+ for (const t of getTestInfo()) {
+ let s = t.name;
+ if (t.suites.length > 0) {
+ s += ` (suites: ${t.suites.join(",")})`;
+ }
+ if (t.excludeByDefault) {
+ s += ` [excluded by default]`;
+ }
+ console.log(s);
+ }
+ });
+
+testingCli
+ .subcommand("runIntegrationtests", "run-integrationtests")
+ .maybeArgument("pattern", clk.STRING, {
+ help: "Glob pattern to select which tests to run",
+ })
+ .maybeOption("suites", ["--suites"], clk.STRING, {
+ help: "Only run selected suites (comma-separated list)",
+ })
+ .flag("dryRun", ["--dry"], {
+ help: "Only print tests that will be selected to run.",
+ })
+ .flag("quiet", ["--quiet"], {
+ help: "Produce less output.",
+ })
+ .action(async (args) => {
+ await runTests({
+ includePattern: args.runIntegrationtests.pattern,
+ suiteSpec: args.runIntegrationtests.suites,
+ dryRun: args.runIntegrationtests.dryRun,
+ verbosity: args.runIntegrationtests.quiet ? 0 : 1,
+ });
+ });
+
+async function read(stream: NodeJS.ReadStream) {
+ const chunks = [];
+ for await (const chunk of stream) chunks.push(chunk);
+ return Buffer.concat(chunks).toString("utf8");
+}
+
+testingCli.subcommand("tvgcheck", "tvgcheck").action(async (args) => {
+ const data = await read(process.stdin);
+
+ const lines = data.match(/[^\r\n]+/g);
+
+ if (!lines) {
+ throw Error("can't split lines");
+ }
+
+ const vals: Record<string, string> = {};
+
+ let inBlindSigningSection = false;
+
+ for (const line of lines) {
+ if (line === "blind signing:") {
+ inBlindSigningSection = true;
+ continue;
+ }
+ if (line[0] !== " ") {
+ inBlindSigningSection = false;
+ continue;
+ }
+ if (inBlindSigningSection) {
+ const m = line.match(/ (\w+) (\w+)/);
+ if (!m) {
+ console.log("bad format");
+ process.exit(2);
+ }
+ vals[m[1]] = m[2];
+ }
+ }
+
+ console.log(vals);
+
+ const req = (k: string) => {
+ if (!vals[k]) {
+ throw Error(`no value for ${k}`);
+ }
+ return decodeCrock(vals[k]);
+ };
+
+ const myBm = rsaBlind(
+ req("message_hash"),
+ req("blinding_key_secret"),
+ req("rsa_public_key"),
+ );
+
+ deepStrictEqual(req("blinded_message"), myBm);
+
+ console.log("check passed!");
+});
+
+export function main() {
+ testingCli.run();
+}
diff --git a/packages/taler-harness/src/integrationtests/scenario-prompt-payment.ts b/packages/taler-harness/src/integrationtests/scenario-prompt-payment.ts
new file mode 100644
index 000000000..ea05de8e9
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/scenario-prompt-payment.ts
@@ -0,0 +1,60 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runPromptPaymentScenario(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ // Set up order.
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ console.log(orderStatus);
+
+ // Wait "forever"
+ await new Promise(() => {});
+}
diff --git a/packages/taler-harness/src/integrationtests/test-age-restrictions-merchant.ts b/packages/taler-harness/src/integrationtests/test-age-restrictions-merchant.ts
new file mode 100644
index 000000000..ff589dd79
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-age-restrictions-merchant.ts
@@ -0,0 +1,201 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { BankApi, WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ getWireMethodForTest,
+ GlobalTestState,
+ MerchantPrivateApi,
+ WalletCli,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ makeTestPayment,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runAgeRestrictionsMerchantTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet: walletOne,
+ bank,
+ exchange,
+ merchant,
+ exchangeBankAccount,
+ } = await createSimpleTestkudosEnvironment(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ ageMaskSpec: "8:10:12:14:16:18:21",
+ },
+ );
+
+ const walletTwo = new WalletCli(t, "walletTwo");
+ const walletThree = new WalletCli(t, "walletThree");
+
+ {
+ const walletZero = new WalletCli(t, "walletZero");
+
+ await withdrawViaBank(t, {
+ wallet: walletZero,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ restrictAge: 13,
+ });
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPayment(t, { wallet: walletZero, merchant, order });
+ await walletZero.runUntilDone();
+ }
+
+ {
+ const wallet = walletOne;
+
+ await withdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ restrictAge: 13,
+ });
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+ await wallet.runUntilDone();
+ }
+
+ {
+ const wallet = walletTwo;
+
+ await withdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ restrictAge: 13,
+ });
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+ await wallet.runUntilDone();
+ }
+
+ {
+ const wallet = walletThree;
+
+ await withdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+ await wallet.runUntilDone();
+ }
+
+ // Pay with coin from tipping
+ {
+ const mbu = await BankApi.createRandomBankUser(bank);
+ const tipReserveResp = await MerchantPrivateApi.createTippingReserve(
+ merchant,
+ "default",
+ {
+ exchange_url: exchange.baseUrl,
+ initial_balance: "TESTKUDOS:10",
+ wire_method: getWireMethodForTest(),
+ },
+ );
+
+ t.assertDeepEqual(
+ tipReserveResp.payto_uri,
+ exchangeBankAccount.accountPaytoUri,
+ );
+
+ await BankApi.adminAddIncoming(bank, {
+ amount: "TESTKUDOS:10",
+ debitAccountPayto: mbu.accountPaytoUri,
+ exchangeBankAccount,
+ reservePub: tipReserveResp.reserve_pub,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ const tip = await MerchantPrivateApi.giveTip(merchant, "default", {
+ amount: "TESTKUDOS:5",
+ justification: "why not?",
+ next_url: "https://example.com/after-tip",
+ });
+
+ const walletTipping = new WalletCli(t, "age-tipping");
+
+ const ptr = await walletTipping.client.call(WalletApiOperation.PrepareTip, {
+ talerTipUri: tip.taler_tip_uri,
+ });
+
+ await walletTipping.client.call(WalletApiOperation.AcceptTip, {
+ walletTipId: ptr.walletTipId,
+ });
+
+ await walletTipping.runUntilDone();
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:4",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPayment(t, { wallet: walletTipping, merchant, order });
+ await walletTipping.runUntilDone();
+ }
+}
+
+runAgeRestrictionsMerchantTest.suites = ["wallet"];
+runAgeRestrictionsMerchantTest.timeoutMs = 120 * 1000;
diff --git a/packages/taler-harness/src/integrationtests/test-age-restrictions-mixed-merchant.ts b/packages/taler-harness/src/integrationtests/test-age-restrictions-mixed-merchant.ts
new file mode 100644
index 000000000..8bf71b63d
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-age-restrictions-mixed-merchant.ts
@@ -0,0 +1,116 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState, WalletCli } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ makeTestPayment,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runAgeRestrictionsMixedMerchantTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet: walletOne,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ ageMaskSpec: "8:10:12:14:16:18:21",
+ mixedAgeRestriction: true,
+ },
+ );
+
+ const walletTwo = new WalletCli(t, "walletTwo");
+ const walletThree = new WalletCli(t, "walletThree");
+
+ {
+ const wallet = walletOne;
+
+ await withdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ restrictAge: 13,
+ });
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+ await wallet.runUntilDone();
+ }
+
+ {
+ const wallet = walletTwo;
+
+ await withdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ restrictAge: 13,
+ });
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+ await wallet.runUntilDone();
+ }
+
+ {
+ const wallet = walletThree;
+
+ await withdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+ await wallet.runUntilDone();
+ }
+}
+
+runAgeRestrictionsMixedMerchantTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-age-restrictions-peer.ts b/packages/taler-harness/src/integrationtests/test-age-restrictions-peer.ts
new file mode 100644
index 000000000..af5b4df52
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-age-restrictions-peer.ts
@@ -0,0 +1,92 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { AbsoluteTime, Duration } from "@gnu-taler/taler-util";
+import { getDefaultNodeWallet2, WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState, WalletCli } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ makeTestPayment,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runAgeRestrictionsPeerTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet: walletOne,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ ageMaskSpec: "8:10:12:14:16:18:21",
+ },
+ );
+
+ const walletTwo = new WalletCli(t, "walletTwo");
+ const walletThree = new WalletCli(t, "walletThree");
+
+ {
+ const wallet = walletOne;
+
+ await withdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ restrictAge: 13,
+ });
+
+ const purse_expiration = AbsoluteTime.toTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const initResp = await wallet.client.call(WalletApiOperation.InitiatePeerPushPayment, {
+ partialContractTerms: {
+ summary: "Hello, World",
+ amount: "TESTKUDOS:1",
+ purse_expiration,
+ },
+ });
+
+ await wallet.runUntilDone();
+
+ const checkResp = await walletTwo.client.call(WalletApiOperation.CheckPeerPushPayment, {
+ talerUri: initResp.talerUri,
+ });
+
+ await walletTwo.client.call(WalletApiOperation.AcceptPeerPushPayment, {
+ peerPushPaymentIncomingId: checkResp.peerPushPaymentIncomingId,
+ });
+
+ await walletTwo.runUntilDone();
+ }
+}
+
+runAgeRestrictionsPeerTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-bank-api.ts b/packages/taler-harness/src/integrationtests/test-bank-api.ts
new file mode 100644
index 000000000..c7a23d3ce
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-bank-api.ts
@@ -0,0 +1,136 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ GlobalTestState,
+ WalletCli,
+ ExchangeService,
+ setupDb,
+ BankService,
+ MerchantService,
+ getPayto,
+} from "../harness/harness.js";
+import { createEddsaKeyPair, encodeCrock } from "@gnu-taler/taler-util";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankApi,
+ BankAccessApi,
+ CreditDebitIndicator,
+} from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runBankApiTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ currency: "TESTKUDOS",
+ httpPort: 8082,
+ database: db.connStr,
+ allowRegistrations: true,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+ await merchant.addDefaultInstance();
+ await merchant.addInstance({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getPayto("minst1")],
+ });
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ });
+
+ console.log("setup done!");
+
+ const bankUser = await BankApi.registerAccount(bank, "user1", "pw1");
+
+ // Make sure that registering twice results in a 409 Conflict
+ {
+ const e = await t.assertThrowsTalerErrorAsync(async () => {
+ await BankApi.registerAccount(bank, "user1", "pw2");
+ });
+ t.assertTrue(e.errorDetail.httpStatusCode === 409);
+ }
+
+ let balResp = await BankAccessApi.getAccountBalance(bank, bankUser);
+
+ console.log(balResp);
+
+ // Check that we got the sign-up bonus.
+ t.assertAmountEquals(balResp.balance.amount, "TESTKUDOS:100");
+ t.assertTrue(
+ balResp.balance.credit_debit_indicator === CreditDebitIndicator.Credit,
+ );
+
+ const res = createEddsaKeyPair();
+
+ await BankApi.adminAddIncoming(bank, {
+ amount: "TESTKUDOS:115",
+ debitAccountPayto: bankUser.accountPaytoUri,
+ exchangeBankAccount: exchangeBankAccount,
+ reservePub: encodeCrock(res.eddsaPub),
+ });
+
+ balResp = await BankAccessApi.getAccountBalance(bank, bankUser);
+ t.assertAmountEquals(balResp.balance.amount, "TESTKUDOS:15");
+ t.assertTrue(
+ balResp.balance.credit_debit_indicator === CreditDebitIndicator.Debit,
+ );
+}
diff --git a/packages/taler-harness/src/integrationtests/test-claim-loop.ts b/packages/taler-harness/src/integrationtests/test-claim-loop.ts
new file mode 100644
index 000000000..a509e3b19
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-claim-loop.ts
@@ -0,0 +1,79 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
+import { URL } from "url";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Run test for the merchant's order lifecycle.
+ *
+ * FIXME: Is this test still necessary? We initially wrote if to confirm/document
+ * assumptions about how the merchant should work.
+ */
+export async function runClaimLoopTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ // Set up order.
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ // Query private order status before claiming it.
+ let orderStatusBefore = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderResp.order_id,
+ },
+ );
+ t.assertTrue(orderStatusBefore.order_status === "unpaid");
+ let statusUrlBefore = new URL(orderStatusBefore.order_status_url);
+
+ // Make wallet claim the unpaid order.
+ t.assertTrue(orderStatusBefore.order_status === "unpaid");
+ const talerPayUri = orderStatusBefore.taler_pay_uri;
+ await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri,
+ });
+
+ // Query private order status after claiming it.
+ let orderStatusAfter = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderResp.order_id,
+ },
+ );
+ t.assertTrue(orderStatusAfter.order_status === "claimed");
+
+ await t.shutdown();
+}
diff --git a/packages/taler-harness/src/integrationtests/test-clause-schnorr.ts b/packages/taler-harness/src/integrationtests/test-clause-schnorr.ts
new file mode 100644
index 000000000..bf42dc4c6
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-clause-schnorr.ts
@@ -0,0 +1,97 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ makeTestPayment,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runClauseSchnorrTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => {
+ return {
+ ...x("TESTKUDOS"),
+ cipher: "CS",
+ };
+ });
+
+ // We need to have at least one RSA denom configured
+ coinConfig.push({
+ cipher: "RSA",
+ rsaKeySize: 1024,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:42",
+ value: "TESTKUDOS:0.0001",
+ feeWithdraw: "TESTKUDOS:42",
+ feeRefresh: "TESTKUDOS:42",
+ feeRefund: "TESTKUDOS:42",
+ name: "rsa_dummy",
+ });
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t, coinConfig);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+ await wallet.runUntilDone();
+
+ // Test JSON normalization of contract terms: Does the wallet
+ // agree with the merchant?
+ const order2 = {
+ summary: "Testing “unicode” characters",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order: order2 });
+ await wallet.runUntilDone();
+
+ // Test JSON normalization of contract terms: Does the wallet
+ // agree with the merchant?
+ const order3 = {
+ summary: "Testing\nNewlines\rAnd\tStuff\nHere\b",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order: order3 });
+
+ await wallet.runUntilDone();
+}
+
+runClauseSchnorrTest.suites = ["experimental-wallet"];
+runClauseSchnorrTest.excludeByDefault = true;
diff --git a/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts b/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts
new file mode 100644
index 000000000..b5ecbee4a
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts
@@ -0,0 +1,126 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { PreparePayResultType, TalerErrorCode } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+
+export async function runDenomUnofferedTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ // Make the exchange forget the denomination.
+ // Effectively we completely reset the exchange,
+ // but keep the exchange master public key.
+
+ await exchange.stop();
+ await exchange.purgeDatabase();
+ await exchange.purgeSecmodKeys();
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ await merchant.stop();
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ {
+ const orderResp = await MerchantPrivateApi.createOrder(
+ merchant,
+ "default",
+ {
+ order: order,
+ },
+ );
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderResp.order_id,
+ },
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const exc = await t.assertThrowsTalerErrorAsync(async () => {
+ await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ proposalId: preparePayResult.proposalId,
+ });
+ });
+
+ t.assertTrue(
+ exc.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED),
+ );
+
+ // FIXME: We might want a more specific error code here!
+ t.assertDeepEqual(
+ exc.errorDetail.innerError.code,
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ );
+ const merchantErrorCode = (exc.errorDetail.innerError.errorResponse as any)
+ .code;
+ t.assertDeepEqual(
+ merchantErrorCode,
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND,
+ );
+ }
+
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ forceUpdate: true,
+ });
+
+ // Now withdrawal should work again.
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ await wallet.runUntilDone();
+
+ const txs = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ console.log(JSON.stringify(txs, undefined, 2));
+}
+
+runDenomUnofferedTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-deposit.ts b/packages/taler-harness/src/integrationtests/test-deposit.ts
new file mode 100644
index 000000000..07382c43e
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-deposit.ts
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, getPayto } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runDepositTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ await wallet.runUntilDone();
+
+ const { depositGroupId } = await wallet.client.call(
+ WalletApiOperation.CreateDepositGroup,
+ {
+ amount: "TESTKUDOS:10",
+ depositPaytoUri: getPayto("foo"),
+ },
+ );
+
+ await wallet.runUntilDone();
+
+ const transactions = await wallet.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log("transactions", JSON.stringify(transactions, undefined, 2));
+ t.assertDeepEqual(transactions.transactions[0].type, "withdrawal");
+ t.assertTrue(!transactions.transactions[0].pending);
+ t.assertDeepEqual(transactions.transactions[1].type, "deposit");
+ t.assertTrue(!transactions.transactions[1].pending);
+ // The raw amount is what ends up on the bank account, which includes
+ // deposit and wire fees.
+ t.assertDeepEqual(transactions.transactions[1].amountRaw, "TESTKUDOS:9.79");
+
+ const trackResult = wallet.client.call(WalletApiOperation.TrackDepositGroup, {
+ depositGroupId,
+ });
+
+ console.log(JSON.stringify(trackResult, undefined, 2));
+}
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-management.ts b/packages/taler-harness/src/integrationtests/test-exchange-management.ts
new file mode 100644
index 000000000..6b63c3741
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-exchange-management.ts
@@ -0,0 +1,285 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ GlobalTestState,
+ WalletCli,
+ setupDb,
+ BankService,
+ ExchangeService,
+ MerchantService,
+ getPayto,
+} from "../harness/harness.js";
+import {
+ WalletApiOperation,
+ BankApi,
+ BankAccessApi,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ ExchangesListResponse,
+ URL,
+ TalerErrorCode,
+ j2s,
+} from "@gnu-taler/taler-util";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectionResponseContext,
+} from "../harness/faultInjection.js";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+
+/**
+ * Test if the wallet handles outdated exchange versions correct.y
+ */
+export async function runExchangeManagementTest(
+ t: GlobalTestState,
+): Promise<void> {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
+
+ bank.setSuggestedExchange(
+ faultyExchange,
+ exchangeBankAccount.accountPaytoUri,
+ );
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ });
+
+ await merchant.addInstance({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ /*
+ * =========================================================================
+ * Check that the exchange can be added to the wallet
+ * (without any faults active).
+ * =========================================================================
+ */
+
+ const wallet = new WalletCli(t);
+
+ let exchangesList: ExchangesListResponse;
+
+ exchangesList = await wallet.client.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+ console.log("exchanges list:", j2s(exchangesList));
+ t.assertTrue(exchangesList.exchanges.length === 0);
+
+ // Try before fault is injected
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: faultyExchange.baseUrl,
+ });
+
+ exchangesList = await wallet.client.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+ t.assertTrue(exchangesList.exchanges.length === 1);
+
+ await wallet.client.call(WalletApiOperation.ListExchanges, {});
+
+ console.log("listing exchanges");
+
+ exchangesList = await wallet.client.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+ t.assertTrue(exchangesList.exchanges.length === 1);
+
+ console.log("got list", exchangesList);
+
+ /*
+ * =========================================================================
+ * Check what happens if the exchange returns something totally
+ * bogus for /keys.
+ * =========================================================================
+ */
+
+ wallet.deleteDatabase();
+
+ exchangesList = await wallet.client.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+ t.assertTrue(exchangesList.exchanges.length === 0);
+
+ faultyExchange.faultProxy.addFault({
+ async modifyResponse(ctx: FaultInjectionResponseContext) {
+ const url = new URL(ctx.request.requestUrl);
+ if (url.pathname === "/keys") {
+ const body = {
+ version: "whaaat",
+ };
+ ctx.responseBody = Buffer.from(JSON.stringify(body), "utf-8");
+ }
+ },
+ });
+
+ const err1 = await t.assertThrowsTalerErrorAsync(async () => {
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: faultyExchange.baseUrl,
+ });
+ });
+
+ // Response is malformed, since it didn't even contain a version code
+ // in a format the wallet can understand.
+ t.assertTrue(
+ err1.errorDetail.code === TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ );
+ exchangesList = await wallet.client.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+ console.log("exchanges list", j2s(exchangesList));
+ t.assertTrue(exchangesList.exchanges.length === 1);
+ t.assertTrue(
+ exchangesList.exchanges[0].lastUpdateErrorInfo?.error.code ===
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ );
+
+ /*
+ * =========================================================================
+ * Check what happens if the exchange returns an old, unsupported
+ * version for /keys
+ * =========================================================================
+ */
+
+ wallet.deleteDatabase();
+ faultyExchange.faultProxy.clearAllFaults();
+
+ faultyExchange.faultProxy.addFault({
+ async modifyResponse(ctx: FaultInjectionResponseContext) {
+ const url = new URL(ctx.request.requestUrl);
+ if (url.pathname === "/keys") {
+ const keys = ctx.responseBody?.toString("utf-8");
+ t.assertTrue(keys != null);
+ const keysJson = JSON.parse(keys);
+ keysJson["version"] = "2:0:0";
+ ctx.responseBody = Buffer.from(JSON.stringify(keysJson), "utf-8");
+ }
+ },
+ });
+
+ const err2 = await t.assertThrowsTalerErrorAsync(async () => {
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: faultyExchange.baseUrl,
+ });
+ });
+
+ t.assertTrue(
+ err2.hasErrorCode(
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ ),
+ );
+
+ exchangesList = await wallet.client.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+ t.assertTrue(exchangesList.exchanges.length === 1);
+ t.assertTrue(
+ exchangesList.exchanges[0].lastUpdateErrorInfo?.error.code ===
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ );
+
+ /*
+ * =========================================================================
+ * Check that the exchange version is also checked when
+ * the exchange is implicitly added via the suggested
+ * exchange of a bank-integrated withdrawal.
+ * =========================================================================
+ */
+
+ // Fault from above is still active!
+
+ // Create withdrawal operation
+
+ const user = await BankApi.createRandomBankUser(bank);
+ const wop = await BankAccessApi.createWithdrawalOperation(
+ bank,
+ user,
+ "TESTKUDOS:10",
+ );
+
+ // Hand it to the wallet
+
+ const wd = await wallet.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ // Make sure the faulty exchange isn't used for the suggestion.
+ t.assertTrue(wd.possibleExchanges.length === 0);
+}
+
+runExchangeManagementTest.suites = ["wallet", "exchange"];
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts b/packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts
new file mode 100644
index 000000000..074126e9f
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts
@@ -0,0 +1,240 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ codecForExchangeKeysJson,
+ DenominationPubKey,
+ Duration,
+ durationFromSpec,
+} from "@gnu-taler/taler-util";
+import {
+ NodeHttpLib,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-wallet-core";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantPrivateApi,
+ MerchantService,
+ setupDb,
+ WalletCli,
+ getPayto,
+} from "../harness/harness.js";
+import { startWithdrawViaBank, withdrawViaBank } from "../harness/helpers.js";
+
+async function applyTimeTravel(
+ timetravelDuration: Duration,
+ s: {
+ exchange?: ExchangeService;
+ merchant?: MerchantService;
+ wallet?: WalletCli;
+ },
+): Promise<void> {
+ if (s.exchange) {
+ await s.exchange.stop();
+ s.exchange.setTimetravel(timetravelDuration);
+ await s.exchange.start();
+ await s.exchange.pingUntilAvailable();
+ }
+
+ if (s.merchant) {
+ await s.merchant.stop();
+ s.merchant.setTimetravel(timetravelDuration);
+ await s.merchant.start();
+ await s.merchant.pingUntilAvailable();
+ }
+
+ if (s.wallet) {
+ console.log("setting wallet time travel to", timetravelDuration);
+ s.wallet.setTimetravel(timetravelDuration);
+ }
+}
+
+const http = new NodeHttpLib();
+
+/**
+ * Basic time travel test.
+ */
+export async function runExchangeTimetravelTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addCoinConfigList(makeNoFeeCoinConfig("TESTKUDOS"));
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ });
+
+ await merchant.addInstance({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
+
+ const keysResp1 = await http.get(exchange.baseUrl + "keys");
+ const keys1 = await readSuccessResponseJsonOrThrow(
+ keysResp1,
+ codecForExchangeKeysJson(),
+ );
+ console.log(
+ "keys 1 (before time travel):",
+ JSON.stringify(keys1, undefined, 2),
+ );
+
+ // Travel into the future, the deposit expiration is two years
+ // into the future.
+ console.log("applying first time travel");
+ await applyTimeTravel(durationFromSpec({ days: 400 }), {
+ wallet,
+ exchange,
+ merchant,
+ });
+
+ const keysResp2 = await http.get(exchange.baseUrl + "keys");
+ const keys2 = await readSuccessResponseJsonOrThrow(
+ keysResp2,
+ codecForExchangeKeysJson(),
+ );
+ console.log(
+ "keys 2 (after time travel):",
+ JSON.stringify(keys2, undefined, 2),
+ );
+
+ const denomPubs1 = keys1.denoms.map((x) => {
+ return {
+ denomPub: x.denom_pub,
+ expireDeposit: AbsoluteTime.stringify(
+ AbsoluteTime.fromTimestamp(x.stamp_expire_deposit),
+ ),
+ };
+ });
+
+ const denomPubs2 = keys2.denoms.map((x) => {
+ return {
+ denomPub: x.denom_pub,
+ expireDeposit: AbsoluteTime.stringify(
+ AbsoluteTime.fromTimestamp(x.stamp_expire_deposit),
+ ),
+ };
+ });
+ const dps2 = new Set(denomPubs2.map((x) => x.denomPub));
+
+ console.log("=== KEYS RESPONSE 1 ===");
+
+ console.log(
+ "list issue date",
+ AbsoluteTime.stringify(AbsoluteTime.fromTimestamp(keys1.list_issue_date)),
+ );
+ console.log("num denoms", keys1.denoms.length);
+ console.log("denoms", JSON.stringify(denomPubs1, undefined, 2));
+
+ console.log("=== KEYS RESPONSE 2 ===");
+
+ console.log(
+ "list issue date",
+ AbsoluteTime.stringify(AbsoluteTime.fromTimestamp(keys2.list_issue_date)),
+ );
+ console.log("num denoms", keys2.denoms.length);
+ console.log("denoms", JSON.stringify(denomPubs2, undefined, 2));
+
+ for (const da of denomPubs1) {
+ let found = false;
+ for (const db of denomPubs2) {
+ const d1 = da.denomPub;
+ const d2 = db.denomPub;
+ if (DenominationPubKey.cmp(d1, d2) === 0) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ console.log("=== ERROR ===");
+ console.log(
+ `denomination with public key ${da.denomPub} is not present in new /keys response`,
+ );
+ console.log(
+ `the new /keys response was issued ${AbsoluteTime.stringify(
+ AbsoluteTime.fromTimestamp(keys2.list_issue_date),
+ )}`,
+ );
+ console.log(
+ `however, the missing denomination has stamp_expire_deposit ${da.expireDeposit}`,
+ );
+ console.log("see above for the verbatim /keys responses");
+ t.assertTrue(false);
+ }
+ }
+}
+
+runExchangeTimetravelTest.suites = ["exchange"];
diff --git a/packages/taler-harness/src/integrationtests/test-fee-regression.ts b/packages/taler-harness/src/integrationtests/test-fee-regression.ts
new file mode 100644
index 000000000..8c5a5bea4
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-fee-regression.ts
@@ -0,0 +1,200 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ GlobalTestState,
+ BankService,
+ ExchangeService,
+ MerchantService,
+ setupDb,
+ WalletCli,
+ getPayto,
+} from "../harness/harness.js";
+import {
+ withdrawViaBank,
+ makeTestPayment,
+ SimpleTestEnvironment,
+} from "../harness/helpers.js";
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ */
+export async function createMyTestkudosEnvironment(
+ t: GlobalTestState,
+): Promise<SimpleTestEnvironment> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ rsaKeySize: 1024,
+ feeDeposit: "TESTKUDOS:0.0025",
+ feeWithdraw: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ };
+
+ exchange.addCoinConfigList([
+ {
+ ...coinCommon,
+ name: "c1",
+ value: "TESTKUDOS:1.28",
+ },
+ {
+ ...coinCommon,
+ name: "c2",
+ value: "TESTKUDOS:0.64",
+ },
+ {
+ ...coinCommon,
+ name: "c3",
+ value: "TESTKUDOS:0.32",
+ },
+ {
+ ...coinCommon,
+ name: "c4",
+ value: "TESTKUDOS:0.16",
+ },
+ {
+ ...coinCommon,
+ name: "c5",
+ value: "TESTKUDOS:0.08",
+ },
+ {
+ ...coinCommon,
+ name: "c5",
+ value: "TESTKUDOS:0.04",
+ },
+ {
+ ...coinCommon,
+ name: "c6",
+ value: "TESTKUDOS:0.02",
+ },
+ {
+ ...coinCommon,
+ name: "c7",
+ value: "TESTKUDOS:0.01",
+ },
+ ]);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addDefaultInstance();
+ await merchant.addInstance({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ wallet,
+ bank,
+ exchangeBankAccount,
+ };
+}
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runFeeRegressionTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createMyTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:1.92",
+ });
+
+ const coins = await wallet.client.call(WalletApiOperation.DumpCoins, {});
+
+ // Make sure we really withdraw one 0.64 and one 1.28 coin.
+ t.assertTrue(coins.coins.length === 2);
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:1.30",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+
+ await wallet.runUntilDone();
+
+ const txs = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ t.assertAmountEquals(txs.transactions[1].amountEffective, "TESTKUDOS:1.30");
+ console.log(txs);
+}
+
+runFeeRegressionTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-forced-selection.ts b/packages/taler-harness/src/integrationtests/test-forced-selection.ts
new file mode 100644
index 000000000..91be11a82
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-forced-selection.ts
@@ -0,0 +1,87 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
+
+/**
+ * Run test for forced denom/coin selection.
+ */
+export async function runForcedSelectionTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t);
+
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10",
+ bankBaseUrl: bank.baseUrl,
+ bankAccessApiBaseUrl: bank.bankAccessApiBaseUrl,
+ forcedDenomSel: {
+ denoms: [
+ {
+ value: "TESTKUDOS:2",
+ count: 3,
+ },
+ ],
+ },
+ });
+
+ await wallet.runUntilDone();
+
+ const coinDump = await wallet.client.call(WalletApiOperation.DumpCoins, {});
+ console.log(coinDump);
+ t.assertDeepEqual(coinDump.coins.length, 3);
+
+ const payResp = await wallet.client.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:3",
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ summary: "bla",
+ forcedCoinSel: {
+ coins: [
+ {
+ value: "TESTKUDOS:2",
+ contribution: "TESTKUDOS:1",
+ },
+ {
+ value: "TESTKUDOS:2",
+ contribution: "TESTKUDOS:1",
+ },
+ {
+ value: "TESTKUDOS:2",
+ contribution: "TESTKUDOS:1",
+ },
+ ],
+ },
+ });
+
+ console.log(j2s(payResp));
+
+ // Without forced selection, we would only use 2 coins.
+ t.assertDeepEqual(payResp.payCoinSelection.coinContributions.length, 3);
+}
+
+runForcedSelectionTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-api-bankaccount.ts b/packages/taler-harness/src/integrationtests/test-libeufin-api-bankaccount.ts
new file mode 100644
index 000000000..c3cbc0608
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-api-bankaccount.ts
@@ -0,0 +1,109 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ NexusUserBundle,
+ LibeufinNexusApi,
+ LibeufinNexusService,
+ LibeufinSandboxService,
+ LibeufinSandboxApi,
+ findNexusPayment,
+} from "../harness/libeufin.js";
+
+/**
+ * Run basic test with LibEuFin.
+ */
+export async function runLibeufinApiBankaccountTest(t: GlobalTestState) {
+ const nexus = await LibeufinNexusService.create(t, {
+ httpPort: 5011,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
+ });
+ await nexus.start();
+ await nexus.pingUntilAvailable();
+
+ await LibeufinNexusApi.createUser(nexus, {
+ username: "one",
+ password: "testing-the-bankaccount-api",
+ });
+ const sandbox = await LibeufinSandboxService.create(t, {
+ httpPort: 5012,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
+ });
+ await sandbox.start();
+ await sandbox.pingUntilAvailable();
+ await LibeufinSandboxApi.createEbicsHost(sandbox, "mock");
+ await LibeufinSandboxApi.createDemobankAccount(
+ "mock",
+ "password-unused",
+ { baseUrl: sandbox.baseUrl + "/demobanks/default/access-api/" },
+ "DE71500105179674997361"
+ );
+ await LibeufinSandboxApi.createDemobankEbicsSubscriber(
+ {
+ hostID: "mock",
+ partnerID: "mock",
+ userID: "mock",
+ },
+ "mock",
+ { baseUrl: sandbox.baseUrl + "/demobanks/default/" }
+ );
+ await LibeufinNexusApi.createEbicsBankConnection(nexus, {
+ name: "bankaccount-api-test-connection",
+ ebicsURL: "http://localhost:5012/ebicsweb",
+ hostID: "mock",
+ userID: "mock",
+ partnerID: "mock",
+ });
+ await LibeufinNexusApi.connectBankConnection(
+ nexus,
+ "bankaccount-api-test-connection",
+ );
+ await LibeufinNexusApi.fetchAccounts(
+ nexus,
+ "bankaccount-api-test-connection",
+ );
+
+ await LibeufinNexusApi.importConnectionAccount(
+ nexus,
+ "bankaccount-api-test-connection",
+ "mock",
+ "local-mock",
+ );
+ await LibeufinSandboxApi.simulateIncomingTransaction(
+ sandbox,
+ "mock", // creditor bankaccount label
+ {
+ debtorIban: "DE84500105176881385584",
+ debtorBic: "BELADEBEXXX",
+ debtorName: "mock2",
+ amount: "1",
+ subject: "mock subject",
+ },
+ );
+ await LibeufinNexusApi.fetchTransactions(nexus, "local-mock");
+ let transactions = await LibeufinNexusApi.getAccountTransactions(
+ nexus,
+ "local-mock",
+ );
+ let el = findNexusPayment("mock subject", transactions.data);
+ t.assertTrue(el instanceof Object);
+}
+
+runLibeufinApiBankaccountTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-api-bankconnection.ts b/packages/taler-harness/src/integrationtests/test-libeufin-api-bankconnection.ts
new file mode 100644
index 000000000..912b7b2ac
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-api-bankconnection.ts
@@ -0,0 +1,56 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import { LibeufinNexusApi, LibeufinNexusService } from "../harness/libeufin.js";
+
+/**
+ * Run basic test with LibEuFin.
+ */
+export async function runLibeufinApiBankconnectionTest(t: GlobalTestState) {
+ const nexus = await LibeufinNexusService.create(t, {
+ httpPort: 5011,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
+ });
+ await nexus.start();
+ await nexus.pingUntilAvailable();
+
+ await LibeufinNexusApi.createUser(nexus, {
+ username: "one",
+ password: "testing-the-bankconnection-api",
+ });
+
+ await LibeufinNexusApi.createEbicsBankConnection(nexus, {
+ name: "bankconnection-api-test-connection",
+ ebicsURL: "http://localhost:5012/ebicsweb",
+ hostID: "mock",
+ userID: "mock",
+ partnerID: "mock",
+ });
+
+ let connections = await LibeufinNexusApi.getAllConnections(nexus);
+ t.assertTrue(connections.data["bankConnections"].length == 1);
+
+ await LibeufinNexusApi.deleteBankConnection(nexus, {
+ bankConnectionId: "bankconnection-api-test-connection",
+ });
+ connections = await LibeufinNexusApi.getAllConnections(nexus);
+ t.assertTrue(connections.data["bankConnections"].length == 0);
+}
+runLibeufinApiBankconnectionTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-api-facade-bad-request.ts b/packages/taler-harness/src/integrationtests/test-libeufin-api-facade-bad-request.ts
new file mode 100644
index 000000000..a1da9e0da
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-api-facade-bad-request.ts
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { URL } from "@gnu-taler/taler-util";
+import axiosImp from "axios";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ launchLibeufinServices,
+ NexusUserBundle,
+ SandboxUserBundle,
+} from "../harness/libeufin.js";
+
+const axios = axiosImp.default;
+
+export async function runLibeufinApiFacadeBadRequestTest(t: GlobalTestState) {
+ /**
+ * User saltetd "01"
+ */
+ const user01nexus = new NexusUserBundle(
+ "01",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user01sandbox = new SandboxUserBundle("01");
+
+ /**
+ * Launch Sandbox and Nexus.
+ */
+ const libeufinServices = await launchLibeufinServices(
+ t,
+ [user01nexus],
+ [user01sandbox],
+ ["twg"],
+ );
+ console.log("malformed facade");
+ const baseUrl = libeufinServices.libeufinNexus.baseUrl;
+ let url = new URL("facades", baseUrl);
+ let resp = await axios.post(
+ url.href,
+ {
+ name: "malformed-facade",
+ type: "taler-wire-gateway",
+ config: {}, // malformation here.
+ },
+ {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ validateStatus: () => true,
+ },
+ );
+ t.assertTrue(resp.status == 400);
+}
+
+runLibeufinApiFacadeBadRequestTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-api-facade.ts b/packages/taler-harness/src/integrationtests/test-libeufin-api-facade.ts
new file mode 100644
index 000000000..946c565d4
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-api-facade.ts
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ SandboxUserBundle,
+ NexusUserBundle,
+ launchLibeufinServices,
+ LibeufinNexusApi,
+} from "../harness/libeufin.js";
+
+/**
+ * Run basic test with LibEuFin.
+ */
+export async function runLibeufinApiFacadeTest(t: GlobalTestState) {
+ /**
+ * User saltetd "01"
+ */
+ const user01nexus = new NexusUserBundle(
+ "01",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user01sandbox = new SandboxUserBundle("01");
+
+ /**
+ * Launch Sandbox and Nexus.
+ */
+ const libeufinServices = await launchLibeufinServices(
+ t,
+ [user01nexus],
+ [user01sandbox],
+ ["twg"],
+ );
+ let resp = await LibeufinNexusApi.getAllFacades(
+ libeufinServices.libeufinNexus,
+ );
+ // check that original facade shows up.
+ t.assertTrue(resp.data["facades"][0]["name"] == user01nexus.twgReq["name"]);
+
+ const twgBaseUrl: string = resp.data["facades"][0]["baseUrl"];
+ t.assertTrue(typeof twgBaseUrl === "string");
+ t.assertTrue(twgBaseUrl.startsWith("http://"));
+ t.assertTrue(twgBaseUrl.endsWith("/"));
+
+ // delete it.
+ resp = await LibeufinNexusApi.deleteFacade(
+ libeufinServices.libeufinNexus,
+ user01nexus.twgReq["name"],
+ );
+ // check that no facades show up.
+ t.assertTrue(!resp.data.hasOwnProperty("facades"));
+}
+
+runLibeufinApiFacadeTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-api-permissions.ts b/packages/taler-harness/src/integrationtests/test-libeufin-api-permissions.ts
new file mode 100644
index 000000000..f8f2d7d80
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-api-permissions.ts
@@ -0,0 +1,64 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ NexusUserBundle,
+ LibeufinNexusApi,
+ LibeufinNexusService,
+} from "../harness/libeufin.js";
+
+/**
+ * Run basic test with LibEuFin.
+ */
+export async function runLibeufinApiPermissionsTest(t: GlobalTestState) {
+ const nexus = await LibeufinNexusService.create(t, {
+ httpPort: 5011,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
+ });
+ await nexus.start();
+ await nexus.pingUntilAvailable();
+
+ const user01nexus = new NexusUserBundle(
+ "01",
+ "http://localhost:5010/ebicsweb",
+ );
+
+ await LibeufinNexusApi.createUser(nexus, user01nexus.userReq);
+ await LibeufinNexusApi.postPermission(
+ nexus,
+ user01nexus.twgTransferPermission,
+ );
+ let transferPermission = await LibeufinNexusApi.getAllPermissions(nexus);
+ let element = transferPermission.data["permissions"].pop();
+ t.assertTrue(
+ element["permissionName"] == "facade.talerwiregateway.transfer" &&
+ element["subjectId"] == "username-01",
+ );
+ let denyTransfer = user01nexus.twgTransferPermission;
+
+ // Now revoke permission.
+ denyTransfer["action"] = "revoke";
+ await LibeufinNexusApi.postPermission(nexus, denyTransfer);
+
+ transferPermission = await LibeufinNexusApi.getAllPermissions(nexus);
+ t.assertTrue(transferPermission.data["permissions"].length == 0);
+}
+
+runLibeufinApiPermissionsTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-api-sandbox-camt.ts b/packages/taler-harness/src/integrationtests/test-libeufin-api-sandbox-camt.ts
new file mode 100644
index 000000000..cb85c1ffc
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-api-sandbox-camt.ts
@@ -0,0 +1,76 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ LibeufinSandboxApi,
+ LibeufinSandboxService,
+} from "../harness/libeufin.js";
+
+// This test only checks that LibEuFin doesn't fail when
+// it generates Camt statements - no assertions take place.
+// Furthermore, it prints the Camt.053 being generated.
+export async function runLibeufinApiSandboxCamtTest(t: GlobalTestState) {
+ const sandbox = await LibeufinSandboxService.create(t, {
+ httpPort: 5012,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
+ });
+ await sandbox.start();
+ await sandbox.pingUntilAvailable();
+
+ await LibeufinSandboxApi.createDemobankAccount(
+ "mock-account-0",
+ "password-unused",
+ { baseUrl: sandbox.baseUrl + "/demobanks/default/access-api/" }
+ );
+ await LibeufinSandboxApi.createDemobankAccount(
+ "mock-account-1",
+ "password-unused",
+ { baseUrl: sandbox.baseUrl + "/demobanks/default/access-api/" }
+ );
+ await sandbox.makeTransaction(
+ "mock-account-0",
+ "mock-account-1",
+ "EUR:1",
+ "+1",
+ );
+ await sandbox.makeTransaction(
+ "mock-account-0",
+ "mock-account-1",
+ "EUR:1",
+ "+1",
+ );
+ await sandbox.makeTransaction(
+ "mock-account-0",
+ "mock-account-1",
+ "EUR:1",
+ "+1",
+ );
+ await sandbox.makeTransaction(
+ "mock-account-1",
+ "mock-account-0",
+ "EUR:5",
+ "minus 5",
+ );
+ await sandbox.c53tick();
+ let ret = await LibeufinSandboxApi.getCamt053(sandbox, "mock-account-1");
+ console.log(ret);
+}
+runLibeufinApiSandboxCamtTest.excludeByDefault = true;
+runLibeufinApiSandboxCamtTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-api-sandbox-transactions.ts b/packages/taler-harness/src/integrationtests/test-libeufin-api-sandbox-transactions.ts
new file mode 100644
index 000000000..24fd9d3ef
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-api-sandbox-transactions.ts
@@ -0,0 +1,69 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ LibeufinSandboxApi,
+ LibeufinSandboxService,
+} from "../harness/libeufin.js";
+
+export async function runLibeufinApiSandboxTransactionsTest(
+ t: GlobalTestState,
+) {
+ const sandbox = await LibeufinSandboxService.create(t, {
+ httpPort: 5012,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
+ });
+ await sandbox.start();
+ await sandbox.pingUntilAvailable();
+ await LibeufinSandboxApi.createDemobankAccount(
+ "mock-account",
+ "password-unused",
+ { baseUrl: sandbox.baseUrl + "/demobanks/default/access-api/" },
+ "DE71500105179674997361"
+ );
+ await LibeufinSandboxApi.simulateIncomingTransaction(
+ sandbox,
+ "mock-account",
+ {
+ debtorIban: "DE84500105176881385584",
+ debtorBic: "BELADEBEXXX",
+ debtorName: "mock2",
+ subject: "mock subject",
+ amount: "1", // EUR is default.
+ },
+ );
+ await LibeufinSandboxApi.simulateIncomingTransaction(
+ sandbox,
+ "mock-account",
+ {
+ debtorIban: "DE84500105176881385584",
+ debtorBic: "BELADEBEXXX",
+ debtorName: "mock2",
+ subject: "mock subject 2",
+ amount: "1.1", // EUR is default.
+ },
+ );
+ let ret = await LibeufinSandboxApi.getAccountInfoWithBalance(
+ sandbox,
+ "mock-account",
+ );
+ t.assertAmountEquals(ret.data.balance, "EUR:2.1");
+}
+runLibeufinApiSandboxTransactionsTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-api-scheduling.ts b/packages/taler-harness/src/integrationtests/test-libeufin-api-scheduling.ts
new file mode 100644
index 000000000..95f4bfaa0
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-api-scheduling.ts
@@ -0,0 +1,106 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ launchLibeufinServices,
+ LibeufinNexusApi,
+ LibeufinNexusService,
+ NexusUserBundle,
+ SandboxUserBundle,
+} from "../harness/libeufin.js";
+
+/**
+ * Test Nexus scheduling API. It creates a task, check whether it shows
+ * up, then deletes it, and check if it's gone. Ideally, a check over the
+ * _liveliness_ of a scheduled task should happen.
+ */
+export async function runLibeufinApiSchedulingTest(t: GlobalTestState) {
+ const nexus = await LibeufinNexusService.create(t, {
+ httpPort: 5011,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
+ });
+ await nexus.start();
+ await nexus.pingUntilAvailable();
+
+ const user01nexus = new NexusUserBundle(
+ "01",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user01sandbox = new SandboxUserBundle("01");
+ await launchLibeufinServices(t, [user01nexus], [user01sandbox]);
+ await LibeufinNexusApi.postTask(nexus, user01nexus.localAccountName, {
+ name: "test-task",
+ cronspec: "* * *",
+ type: "fetch",
+ params: {
+ level: "all",
+ rangeType: "all",
+ },
+ });
+ let resp = await LibeufinNexusApi.getTasks(
+ nexus,
+ user01nexus.localAccountName,
+ "test-task",
+ );
+ t.assertTrue(resp.data["taskName"] == "test-task");
+ await LibeufinNexusApi.deleteTask(
+ nexus,
+ user01nexus.localAccountName,
+ "test-task",
+ );
+ try {
+ await LibeufinNexusApi.getTasks(
+ nexus,
+ user01nexus.localAccountName,
+ "test-task",
+ );
+ } catch (err: any) {
+ t.assertTrue(err.response.status == 404);
+ }
+
+ // Same with submit task.
+ await LibeufinNexusApi.postTask(nexus, user01nexus.localAccountName, {
+ name: "test-task",
+ cronspec: "* * *",
+ type: "submit",
+ params: {},
+ });
+ resp = await LibeufinNexusApi.getTasks(
+ nexus,
+ user01nexus.localAccountName,
+ "test-task",
+ );
+ t.assertTrue(resp.data["taskName"] == "test-task");
+ await LibeufinNexusApi.deleteTask(
+ nexus,
+ user01nexus.localAccountName,
+ "test-task",
+ );
+ try {
+ await LibeufinNexusApi.getTasks(
+ nexus,
+ user01nexus.localAccountName,
+ "test-task",
+ );
+ } catch (err: any) {
+ t.assertTrue(err.response.status == 404);
+ }
+}
+runLibeufinApiSchedulingTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-api-users.ts b/packages/taler-harness/src/integrationtests/test-libeufin-api-users.ts
new file mode 100644
index 000000000..bc3103c7e
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-api-users.ts
@@ -0,0 +1,63 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import { LibeufinNexusApi, LibeufinNexusService } from "../harness/libeufin.js";
+
+/**
+ * Run basic test with LibEuFin.
+ */
+export async function runLibeufinApiUsersTest(t: GlobalTestState) {
+ const nexus = await LibeufinNexusService.create(t, {
+ httpPort: 5011,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
+ });
+ await nexus.start();
+ await nexus.pingUntilAvailable();
+
+ await LibeufinNexusApi.createUser(nexus, {
+ username: "one",
+ password: "will-be-changed",
+ });
+
+ await LibeufinNexusApi.changePassword(
+ nexus,
+ "one",
+ {
+ newPassword: "got-changed",
+ },
+ {
+ auth: {
+ username: "admin",
+ password: "test",
+ },
+ },
+ );
+
+ let resp = await LibeufinNexusApi.getUser(nexus, {
+ auth: {
+ username: "one",
+ password: "got-changed",
+ },
+ });
+ console.log(resp.data);
+ t.assertTrue(resp.data["username"] == "one" && !resp.data["superuser"]);
+}
+
+runLibeufinApiUsersTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-bad-gateway.ts b/packages/taler-harness/src/integrationtests/test-libeufin-bad-gateway.ts
new file mode 100644
index 000000000..53aacca84
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-bad-gateway.ts
@@ -0,0 +1,74 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, delayMs } from "../harness/harness.js";
+import {
+ NexusUserBundle,
+ LibeufinNexusApi,
+ LibeufinNexusService,
+ LibeufinSandboxService,
+} from "../harness/libeufin.js";
+
+/**
+ * Testing how Nexus reacts when the Sandbox is unreachable.
+ * Typically, because the user specified a wrong EBICS endpoint.
+ */
+export async function runLibeufinBadGatewayTest(t: GlobalTestState) {
+ /**
+ * User saltetd "01"
+ */
+ const user01nexus = new NexusUserBundle(
+ "01", "http://localhost:5010/not-found", // the EBICS endpoint at Sandbox
+ );
+
+ // Start Nexus
+ const libeufinNexus = await LibeufinNexusService.create(t, {
+ httpPort: 5011,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
+ });
+ await libeufinNexus.start();
+ await libeufinNexus.pingUntilAvailable();
+
+ // Start Sandbox
+ const libeufinSandbox = await LibeufinSandboxService.create(t, {
+ httpPort: 5010,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
+ });
+ await libeufinSandbox.start();
+ await libeufinSandbox.pingUntilAvailable();
+
+ // Connecting to a non-existent Sandbox endpoint.
+ await LibeufinNexusApi.createEbicsBankConnection(
+ libeufinNexus,
+ user01nexus.connReq
+ );
+
+ // 502 Bad Gateway expected.
+ try {
+ await LibeufinNexusApi.connectBankConnection(
+ libeufinNexus,
+ user01nexus.connReq.name,
+ );
+ } catch(e: any) {
+ t.assertTrue(e.response.status == 502);
+ return;
+ }
+ t.assertTrue(false);
+}
+runLibeufinBadGatewayTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-basic.ts b/packages/taler-harness/src/integrationtests/test-libeufin-basic.ts
new file mode 100644
index 000000000..94fd76683
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-basic.ts
@@ -0,0 +1,308 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { AbsoluteTime, MerchantContractTerms, Duration } from "@gnu-taler/taler-util";
+import {
+ WalletApiOperation,
+ HarnessExchangeBankAccount,
+} from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ DbInfo,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ setupDb,
+ WalletCli,
+} from "../harness/harness.js";
+import { makeTestPayment } from "../harness/helpers.js";
+import {
+ LibeufinNexusApi,
+ LibeufinNexusService,
+ LibeufinSandboxApi,
+ LibeufinSandboxService,
+} from "../harness/libeufin.js";
+
+const exchangeIban = "DE71500105179674997361";
+const customerIban = "DE84500105176881385584";
+const customerBic = "BELADEBEXXX";
+const merchantIban = "DE42500105171245624648";
+
+export interface LibeufinTestEnvironment {
+ commonDb: DbInfo;
+ exchange: ExchangeService;
+ exchangeBankAccount: HarnessExchangeBankAccount;
+ merchant: MerchantService;
+ wallet: WalletCli;
+ libeufinSandbox: LibeufinSandboxService;
+ libeufinNexus: LibeufinNexusService;
+}
+
+/**
+ * Create a Taler environment with LibEuFin and an EBICS account.
+ */
+export async function createLibeufinTestEnvironment(
+ t: GlobalTestState,
+ coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("EUR")),
+): Promise<LibeufinTestEnvironment> {
+ const db = await setupDb(t);
+
+ const libeufinSandbox = await LibeufinSandboxService.create(t, {
+ httpPort: 5010,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
+ });
+
+ await libeufinSandbox.start();
+ await libeufinSandbox.pingUntilAvailable();
+
+ const libeufinNexus = await LibeufinNexusService.create(t, {
+ httpPort: 5011,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
+ });
+
+ await libeufinNexus.start();
+ await libeufinNexus.pingUntilAvailable();
+
+ await LibeufinSandboxApi.createEbicsHost(libeufinSandbox, "host01");
+ // Subscriber and bank Account for the exchange
+ await LibeufinSandboxApi.createDemobankAccount(
+ "exchangeacct",
+ "password-unused",
+ { baseUrl: libeufinSandbox.baseUrl + "/demobanks/default/access-api/" },
+ exchangeIban
+ );
+ await LibeufinSandboxApi.createDemobankEbicsSubscriber(
+ {
+ hostID: "host01",
+ partnerID: "partner01",
+ userID: "user01",
+ },
+ "exchangeacct",
+ { baseUrl: libeufinSandbox.baseUrl + "/demobanks/default/" }
+ );
+
+ await LibeufinSandboxApi.createDemobankAccount(
+ "merchantacct",
+ "password-unused",
+ { baseUrl: libeufinSandbox.baseUrl + "/demobanks/default/access-api/" },
+ merchantIban
+ );
+ await LibeufinSandboxApi.createDemobankEbicsSubscriber(
+ {
+ hostID: "host01",
+ partnerID: "partner02",
+ userID: "user02",
+ },
+ "merchantacct",
+ { baseUrl: libeufinSandbox.baseUrl + "/demobanks/default/" },
+ );
+
+ await LibeufinNexusApi.createEbicsBankConnection(libeufinNexus, {
+ name: "myconn",
+ ebicsURL: "http://localhost:5010/ebicsweb",
+ hostID: "host01",
+ partnerID: "partner01",
+ userID: "user01",
+ });
+ await LibeufinNexusApi.connectBankConnection(libeufinNexus, "myconn");
+ await LibeufinNexusApi.fetchAccounts(libeufinNexus, "myconn");
+ await LibeufinNexusApi.importConnectionAccount(
+ libeufinNexus,
+ "myconn",
+ "exchangeacct",
+ "myacct",
+ );
+
+ await LibeufinNexusApi.createTwgFacade(libeufinNexus, {
+ name: "twg1",
+ accountName: "myacct",
+ connectionName: "myconn",
+ currency: "EUR",
+ reserveTransferLevel: "report",
+ });
+
+ await LibeufinNexusApi.createUser(libeufinNexus, {
+ username: "twguser",
+ password: "twgpw",
+ });
+
+ await LibeufinNexusApi.postPermission(libeufinNexus, {
+ action: "grant",
+ permission: {
+ subjectType: "user",
+ subjectId: "twguser",
+ resourceType: "facade",
+ resourceId: "twg1",
+ permissionName: "facade.talerWireGateway.history",
+ },
+ });
+
+ await LibeufinNexusApi.postPermission(libeufinNexus, {
+ action: "grant",
+ permission: {
+ subjectType: "user",
+ subjectId: "twguser",
+ resourceType: "facade",
+ resourceId: "twg1",
+ permissionName: "facade.talerWireGateway.transfer",
+ },
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "EUR",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "EUR",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount: HarnessExchangeBankAccount = {
+ accountName: "twguser",
+ accountPassword: "twgpw",
+ accountPaytoUri: `payto://iban/${exchangeIban}?receiver-name=Exchange`,
+ wireGatewayApiBaseUrl:
+ "http://localhost:5011/facades/twg1/taler-wire-gateway/",
+ };
+
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [`payto://iban/${merchantIban}?receiver-name=Merchant`],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.getZero(),
+ ),
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ wallet,
+ exchangeBankAccount,
+ libeufinNexus,
+ libeufinSandbox,
+ };
+}
+
+/**
+ * Run basic test with LibEuFin.
+ */
+export async function runLibeufinBasicTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, exchange, merchant, libeufinSandbox, libeufinNexus } =
+ await createLibeufinTestEnvironment(t);
+
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const wr = await wallet.client.call(
+ WalletApiOperation.AcceptManualWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "EUR:15",
+ },
+ );
+
+ const reservePub: string = wr.reservePub;
+
+ await LibeufinSandboxApi.simulateIncomingTransaction(
+ libeufinSandbox,
+ "exchangeacct",
+ {
+ amount: "15.00",
+ debtorBic: customerBic,
+ debtorIban: customerIban,
+ debtorName: "Jane Customer",
+ subject: `Taler Top-up ${reservePub}`,
+ },
+ );
+
+ await LibeufinNexusApi.fetchTransactions(libeufinNexus, "myacct");
+
+ await exchange.runWirewatchOnce();
+
+ await wallet.runUntilDone();
+
+ const bal = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ console.log("balances", JSON.stringify(bal, undefined, 2));
+ t.assertAmountEquals(bal.balances[0].available, "EUR:14.7");
+
+ const order: Partial<MerchantContractTerms> = {
+ summary: "Buy me!",
+ amount: "EUR:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ wire_transfer_deadline: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+
+ await exchange.runAggregatorOnce();
+ await exchange.runTransferOnce();
+
+ await LibeufinNexusApi.submitAllPaymentInitiations(libeufinNexus, "myacct");
+
+ const exchangeTransactions = await LibeufinSandboxApi.getAccountTransactions(
+ libeufinSandbox,
+ "exchangeacct",
+ );
+
+ console.log(
+ "exchange transactions:",
+ JSON.stringify(exchangeTransactions, undefined, 2),
+ );
+
+ t.assertDeepEqual(
+ exchangeTransactions.payments[0].creditDebitIndicator,
+ "credit",
+ );
+ t.assertDeepEqual(
+ exchangeTransactions.payments[1].creditDebitIndicator,
+ "debit",
+ );
+ t.assertDeepEqual(exchangeTransactions.payments[1].debtorIban, exchangeIban);
+ t.assertDeepEqual(
+ exchangeTransactions.payments[1].creditorIban,
+ merchantIban,
+ );
+}
+runLibeufinBasicTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-c5x.ts b/packages/taler-harness/src/integrationtests/test-libeufin-c5x.ts
new file mode 100644
index 000000000..2ba29656a
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-c5x.ts
@@ -0,0 +1,147 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ launchLibeufinServices,
+ LibeufinNexusApi,
+ NexusUserBundle,
+ SandboxUserBundle,
+} from "../harness/libeufin.js";
+
+/**
+ * This test checks how the C52 and C53 coordinate. It'll test
+ * whether fresh transactions stop showing as C52 after they get
+ * included in a bank statement.
+ */
+export async function runLibeufinC5xTest(t: GlobalTestState) {
+ /**
+ * User saltetd "01"
+ */
+ const user01nexus = new NexusUserBundle(
+ "01",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user01sandbox = new SandboxUserBundle("01");
+
+ /**
+ * User saltetd "02".
+ */
+ const user02nexus = new NexusUserBundle(
+ "02",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user02sandbox = new SandboxUserBundle("02");
+
+ /**
+ * Launch Sandbox and Nexus.
+ */
+ const libeufinServices = await launchLibeufinServices(
+ t,
+ [user01nexus, user02nexus],
+ [user01sandbox, user02sandbox],
+ ["twg"],
+ );
+
+ // Check that C52 and C53 have zero entries.
+
+ // C52
+ await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ "all", // range
+ "report", // level
+ );
+ // C53
+ await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ "all", // range
+ "statement", // level
+ );
+ const nexusTxs = await LibeufinNexusApi.getAccountTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ );
+ t.assertTrue(nexusTxs.data["transactions"].length == 0);
+
+ // Addressing one payment to user 01
+ await libeufinServices.libeufinSandbox.makeTransaction(
+ user02sandbox.ebicsBankAccount.label, // debit
+ user01sandbox.ebicsBankAccount.label, // credit
+ "EUR:10",
+ "first payment",
+ );
+
+ let expectOne = await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ "all", // range
+ "report", // C52
+ );
+ t.assertTrue(expectOne.data.newTransactions == 1);
+ t.assertTrue(expectOne.data.downloadedTransactions == 1);
+
+ /* Expect zero payments being downloaded because the
+ * previous request consumed already the one pending
+ * payment.
+ */
+ let expectZero = await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ "all", // range
+ "report", // C52
+ );
+ t.assertTrue(expectZero.data.newTransactions == 0);
+ t.assertTrue(expectZero.data.downloadedTransactions == 0);
+
+ /**
+ * A statement should still account zero payments because
+ * so far the payment made before is still pending.
+ */
+ expectZero = await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ "all", // range
+ "statement", // C53
+ );
+ t.assertTrue(expectZero.data.newTransactions == 0);
+ t.assertTrue(expectZero.data.downloadedTransactions == 0);
+
+ /**
+ * Ticking now. That books any pending transaction.
+ */
+ await libeufinServices.libeufinSandbox.c53tick();
+
+ /**
+ * A statement is now expected to download the transaction,
+ * although that got already ingested along the report
+ * earlier. Thus the transaction counts as downloaded but
+ * not as new.
+ */
+ expectOne = await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ "all", // range
+ "statement", // C53
+ );
+ t.assertTrue(expectOne.data.downloadedTransactions == 1);
+ t.assertTrue(expectOne.data.newTransactions == 0);
+}
+runLibeufinC5xTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-facade-anastasis.ts b/packages/taler-harness/src/integrationtests/test-libeufin-facade-anastasis.ts
new file mode 100644
index 000000000..1ed258c3a
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-facade-anastasis.ts
@@ -0,0 +1,169 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ SandboxUserBundle,
+ NexusUserBundle,
+ launchLibeufinServices,
+ LibeufinNexusApi,
+ LibeufinSandboxApi,
+} from "../harness/libeufin.js";
+
+/**
+ * Testing the Anastasis API, offered by the Anastasis facade.
+ */
+export async function runLibeufinAnastasisFacadeTest(t: GlobalTestState) {
+ /**
+ * User saltetd "01"
+ */
+ const user01nexus = new NexusUserBundle(
+ "01",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user01sandbox = new SandboxUserBundle("01");
+
+ /**
+ * Launch Sandbox and Nexus.
+ */
+ const libeufinServices = await launchLibeufinServices(
+ t,
+ [user01nexus],
+ [user01sandbox],
+ ["anastasis"], // create only one Anastasis facade.
+ );
+ let resp = await LibeufinNexusApi.getAllFacades(
+ libeufinServices.libeufinNexus,
+ );
+ // check that original facade shows up.
+ t.assertTrue(resp.data["facades"][0]["name"] == user01nexus.anastasisReq["name"]);
+ const anastasisBaseUrl: string = resp.data["facades"][0]["baseUrl"];
+ t.assertTrue(typeof anastasisBaseUrl === "string");
+ t.assertTrue(anastasisBaseUrl.startsWith("http://"));
+ t.assertTrue(anastasisBaseUrl.endsWith("/"));
+
+ await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ );
+
+ await LibeufinNexusApi.postPermission(
+ libeufinServices.libeufinNexus, {
+ action: "grant",
+ permission: {
+ subjectId: user01nexus.userReq.username,
+ subjectType: "user",
+ resourceType: "facade",
+ resourceId: user01nexus.anastasisReq.name,
+ permissionName: "facade.anastasis.history",
+ },
+ }
+ );
+
+ // check if empty.
+ let txsEmpty = await LibeufinNexusApi.getAnastasisTransactions(
+ libeufinServices.libeufinNexus,
+ anastasisBaseUrl, {delta: 5})
+
+ t.assertTrue(txsEmpty.data.incoming_transactions.length == 0);
+
+ LibeufinSandboxApi.simulateIncomingTransaction(
+ libeufinServices.libeufinSandbox,
+ user01sandbox.ebicsBankAccount.label,
+ {
+ debtorIban: "ES3314655813489414469157",
+ debtorBic: "BCMAESM1XXX",
+ debtorName: "Mock Donor",
+ subject: "Anastasis donation",
+ amount: "3", // Sandbox takes currency from its 'config'
+ },
+ )
+
+ LibeufinSandboxApi.simulateIncomingTransaction(
+ libeufinServices.libeufinSandbox,
+ user01sandbox.ebicsBankAccount.label,
+ {
+ debtorIban: "ES3314655813489414469157",
+ debtorBic: "BCMAESM1XXX",
+ debtorName: "Mock Donor",
+ subject: "another Anastasis donation",
+ amount: "1", // Sandbox takes currency from its "config"
+ },
+ )
+
+ await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ );
+
+ let txs = await LibeufinNexusApi.getAnastasisTransactions(
+ libeufinServices.libeufinNexus,
+ anastasisBaseUrl,
+ {delta: 5},
+ user01nexus.userReq.username,
+ user01nexus.userReq.password,
+ );
+
+ // check the two payments show up
+ let txsList = txs.data.incoming_transactions
+ t.assertTrue(txsList.length == 2);
+ t.assertTrue([txsList[0].subject, txsList[1].subject].includes("Anastasis donation"));
+ t.assertTrue([txsList[0].subject, txsList[1].subject].includes("another Anastasis donation"));
+ t.assertTrue(txsList[0].row_id == 1)
+ t.assertTrue(txsList[1].row_id == 2)
+
+ LibeufinSandboxApi.simulateIncomingTransaction(
+ libeufinServices.libeufinSandbox,
+ user01sandbox.ebicsBankAccount.label,
+ {
+ debtorIban: "ES3314655813489414469157",
+ debtorBic: "BCMAESM1XXX",
+ debtorName: "Mock Donor",
+ subject: "last Anastasis donation",
+ amount: "10.10", // Sandbox takes currency from its "config"
+ },
+ )
+
+ await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ );
+
+ let txsLast = await LibeufinNexusApi.getAnastasisTransactions(
+ libeufinServices.libeufinNexus,
+ anastasisBaseUrl,
+ {delta: 5, start: 2},
+ user01nexus.userReq.username,
+ user01nexus.userReq.password,
+ );
+ console.log(txsLast.data.incoming_transactions[0].subject == "last Anastasis donation");
+
+ let txsReverse = await LibeufinNexusApi.getAnastasisTransactions(
+ libeufinServices.libeufinNexus,
+ anastasisBaseUrl,
+ {delta: -5, start: 4},
+ user01nexus.userReq.username,
+ user01nexus.userReq.password,
+ );
+ t.assertTrue(txsReverse.data.incoming_transactions[0].row_id == 3);
+ t.assertTrue(txsReverse.data.incoming_transactions[1].row_id == 2);
+ t.assertTrue(txsReverse.data.incoming_transactions[2].row_id == 1);
+}
+
+runLibeufinAnastasisFacadeTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-keyrotation.ts b/packages/taler-harness/src/integrationtests/test-libeufin-keyrotation.ts
new file mode 100644
index 000000000..21bf07de2
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-keyrotation.ts
@@ -0,0 +1,79 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ SandboxUserBundle,
+ NexusUserBundle,
+ launchLibeufinServices,
+ LibeufinSandboxApi,
+ LibeufinNexusApi,
+} from "../harness/libeufin.js";
+
+/**
+ * Run basic test with LibEuFin.
+ */
+export async function runLibeufinKeyrotationTest(t: GlobalTestState) {
+ /**
+ * User saltetd "01"
+ */
+ const user01nexus = new NexusUserBundle(
+ "01",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user01sandbox = new SandboxUserBundle("01");
+
+ /**
+ * Launch Sandbox and Nexus.
+ */
+ const libeufinServices = await launchLibeufinServices(
+ t, [user01nexus], [user01sandbox],
+ );
+
+ await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ );
+
+ /* Rotate the Sandbox keys, and fetch the transactions again */
+ await LibeufinSandboxApi.rotateKeys(
+ libeufinServices.libeufinSandbox,
+ user01sandbox.ebicsBankAccount.subscriber.hostID,
+ );
+
+ try {
+ await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ );
+ } catch (e: any) {
+ /**
+ * Asserting that Nexus responded with a 500 Internal server
+ * error, because the bank signed the last response with a new
+ * key pair that was never downloaded by Nexus.
+ *
+ * NOTE: the bank accepted the request addressed to the old
+ * public key. Should it in this case reject the request even
+ * before trying to verify it?
+ */
+ t.assertTrue(e.response.status == 500);
+ t.assertTrue(e.response.data.code == 9000);
+ }
+}
+runLibeufinKeyrotationTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-nexus-balance.ts b/packages/taler-harness/src/integrationtests/test-libeufin-nexus-balance.ts
new file mode 100644
index 000000000..850b0f1d9
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-nexus-balance.ts
@@ -0,0 +1,118 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ SandboxUserBundle,
+ NexusUserBundle,
+ launchLibeufinServices,
+ LibeufinNexusApi,
+ LibeufinCli
+} from "../harness/libeufin.js";
+
+/**
+ * This test checks how the C52 and C53 coordinate. It'll test
+ * whether fresh transactions stop showing as C52 after they get
+ * included in a bank statement.
+ */
+export async function runLibeufinNexusBalanceTest(t: GlobalTestState) {
+ /**
+ * User saltetd "01"
+ */
+ const user01nexus = new NexusUserBundle(
+ "01",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user01sandbox = new SandboxUserBundle("01");
+
+ /**
+ * User saltetd "02".
+ */
+ const user02nexus = new NexusUserBundle(
+ "02",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user02sandbox = new SandboxUserBundle("02");
+
+ /**
+ * Launch Sandbox and Nexus.
+ */
+ const libeufinServices = await launchLibeufinServices(
+ t,
+ [user01nexus, user02nexus],
+ [user01sandbox, user02sandbox],
+ ["twg"],
+ );
+
+ // user 01 gets 10
+ await libeufinServices.libeufinSandbox.makeTransaction(
+ user02sandbox.ebicsBankAccount.label, // debit
+ user01sandbox.ebicsBankAccount.label, // credit
+ "EUR:10",
+ "first payment",
+ );
+ // user 01 gets another 10
+ await libeufinServices.libeufinSandbox.makeTransaction(
+ user02sandbox.ebicsBankAccount.label, // debit
+ user01sandbox.ebicsBankAccount.label, // credit
+ "EUR:10",
+ "second payment",
+ );
+
+ await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ "all", // range
+ "report", // level
+ );
+
+ // Check that user 01 has 20, via Nexus.
+ let accountInfo = await LibeufinNexusApi.getBankAccount(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ );
+ t.assertAmountEquals(accountInfo.data.lastSeenBalance, "EUR:20");
+
+ // Booking the first two transactions.
+ await libeufinServices.libeufinSandbox.c53tick();
+
+ // user 01 gives 30
+ await libeufinServices.libeufinSandbox.makeTransaction(
+ user01sandbox.ebicsBankAccount.label,
+ user02sandbox.ebicsBankAccount.label,
+ "EUR:30",
+ "third payment",
+ );
+
+ await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ "all", // range
+ "report", // level
+ );
+
+ let accountInfoDebit = await LibeufinNexusApi.getBankAccount(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ );
+ t.assertDeepEqual(accountInfoDebit.data.lastSeenBalance, "-EUR:10");
+}
+
+runLibeufinNexusBalanceTest.suites = ["libeufin"];
+runLibeufinNexusBalanceTest.excludeByDefault = true;
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-refund-multiple-users.ts b/packages/taler-harness/src/integrationtests/test-libeufin-refund-multiple-users.ts
new file mode 100644
index 000000000..245f34331
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-refund-multiple-users.ts
@@ -0,0 +1,104 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, delayMs } from "../harness/harness.js";
+import {
+ SandboxUserBundle,
+ NexusUserBundle,
+ launchLibeufinServices,
+ LibeufinSandboxApi,
+ LibeufinNexusApi,
+} from "../harness/libeufin.js";
+
+/**
+ * User 01 expects a refund from user 02, and expectedly user 03
+ * should not be involved in the process.
+ */
+export async function runLibeufinRefundMultipleUsersTest(t: GlobalTestState) {
+ /**
+ * User saltetd "01"
+ */
+ const user01nexus = new NexusUserBundle(
+ "01",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user01sandbox = new SandboxUserBundle("01");
+
+ /**
+ * User saltetd "02"
+ */
+ const user02nexus = new NexusUserBundle(
+ "02",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user02sandbox = new SandboxUserBundle("02");
+
+ /**
+ * User saltetd "03"
+ */
+ const user03nexus = new NexusUserBundle(
+ "03",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user03sandbox = new SandboxUserBundle("03");
+
+ /**
+ * Launch Sandbox and Nexus.
+ */
+ const libeufinServices = await launchLibeufinServices(
+ t,
+ [user01nexus, user02nexus],
+ [user01sandbox, user02sandbox],
+ ["twg"],
+ );
+
+ // user 01 gets the payment
+ await libeufinServices.libeufinSandbox.makeTransaction(
+ user02sandbox.ebicsBankAccount.label, // debit
+ user01sandbox.ebicsBankAccount.label, // credit
+ "EUR:1",
+ "not a public key",
+ );
+
+ // user 01 fetches the payments
+ await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ );
+
+ // user 01 tries to submit the reimbursement, as
+ // the payment didn't have a valid public key in
+ // the subject.
+ await LibeufinNexusApi.submitInitiatedPayment(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ "1", // so far the only one that can exist.
+ );
+
+ // user 02 checks whether a reimbursement arrived.
+ let history = await LibeufinSandboxApi.getAccountTransactions(
+ libeufinServices.libeufinSandbox,
+ user02sandbox.ebicsBankAccount["label"],
+ );
+ // reimbursement arrived IFF the total payments are 2:
+ // 1 the original (faulty) transaction + 1 the reimbursement.
+ t.assertTrue(history["payments"].length == 2);
+}
+
+runLibeufinRefundMultipleUsersTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-refund.ts b/packages/taler-harness/src/integrationtests/test-libeufin-refund.ts
new file mode 100644
index 000000000..9d90121a0
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-refund.ts
@@ -0,0 +1,101 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, delayMs } from "../harness/harness.js";
+import {
+ SandboxUserBundle,
+ NexusUserBundle,
+ launchLibeufinServices,
+ LibeufinSandboxApi,
+ LibeufinNexusApi,
+} from "../harness/libeufin.js";
+
+/**
+ * Run basic test with LibEuFin.
+ */
+export async function runLibeufinRefundTest(t: GlobalTestState) {
+ /**
+ * User saltetd "01"
+ */
+ const user01nexus = new NexusUserBundle(
+ "01",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user01sandbox = new SandboxUserBundle("01");
+
+ /**
+ * User saltetd "02"
+ */
+ const user02nexus = new NexusUserBundle(
+ "02",
+ "http://localhost:5010/ebicsweb",
+ );
+ const user02sandbox = new SandboxUserBundle("02");
+
+ /**
+ * Launch Sandbox and Nexus.
+ */
+ const libeufinServices = await launchLibeufinServices(
+ t,
+ [user01nexus, user02nexus],
+ [user01sandbox, user02sandbox],
+ ["twg"],
+ );
+
+ // user 02 pays user 01 with a faulty (non Taler) subject.
+ await libeufinServices.libeufinSandbox.makeTransaction(
+ user02sandbox.ebicsBankAccount.label, // debit
+ user01sandbox.ebicsBankAccount.label, // credit
+ "EUR:1",
+ "not a public key",
+ );
+
+ // The bad payment should be now ingested and prepared as
+ // a reimbursement.
+ await LibeufinNexusApi.fetchTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ );
+ // Check that the payment arrived at the Nexus.
+ const nexusTxs = await LibeufinNexusApi.getAccountTransactions(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ );
+ t.assertTrue(nexusTxs.data["transactions"].length == 1);
+
+ // Submit the reimbursement
+ await LibeufinNexusApi.submitInitiatedPayment(
+ libeufinServices.libeufinNexus,
+ user01nexus.localAccountName,
+ // The initiated payment (= the reimbursement) ID below
+ // got set by the Taler facade; at this point only one must
+ // exist. If "1" is not found, a 404 will make this test fail.
+ "1",
+ );
+
+ // user 02 checks whether the reimbursement arrived.
+ let history = await LibeufinSandboxApi.getAccountTransactions(
+ libeufinServices.libeufinSandbox,
+ user02sandbox.ebicsBankAccount["label"],
+ );
+ // 2 payments must exist: 1 the original (faulty) payment +
+ // 1 the reimbursement.
+ t.assertTrue(history["payments"].length == 2);
+}
+runLibeufinRefundTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-sandbox-wire-transfer-cli.ts b/packages/taler-harness/src/integrationtests/test-libeufin-sandbox-wire-transfer-cli.ts
new file mode 100644
index 000000000..e56cb3d68
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-sandbox-wire-transfer-cli.ts
@@ -0,0 +1,85 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ LibeufinSandboxApi,
+ LibeufinSandboxService,
+} from "../harness/libeufin.js";
+
+export async function runLibeufinSandboxWireTransferCliTest(
+ t: GlobalTestState,
+) {
+ const sandbox = await LibeufinSandboxService.create(t, {
+ httpPort: 5012,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
+ });
+ await sandbox.start();
+ await sandbox.pingUntilAvailable();
+ await LibeufinSandboxApi.createDemobankAccount(
+ "mock-account",
+ "password-unused",
+ { baseUrl: sandbox.baseUrl + "/demobanks/default/access-api/" },
+ "DE71500105179674997361"
+ );
+ await LibeufinSandboxApi.createDemobankAccount(
+ "mock-account-2",
+ "password-unused",
+ { baseUrl: sandbox.baseUrl + "/demobanks/default/access-api/" },
+ "DE71500105179674997364"
+ );
+
+ await sandbox.makeTransaction(
+ "mock-account",
+ "mock-account-2",
+ "EUR:1",
+ "one!",
+ );
+ await sandbox.makeTransaction(
+ "mock-account",
+ "mock-account-2",
+ "EUR:1",
+ "two!",
+ );
+ await sandbox.makeTransaction(
+ "mock-account",
+ "mock-account-2",
+ "EUR:1",
+ "three!",
+ );
+ await sandbox.makeTransaction(
+ "mock-account-2",
+ "mock-account",
+ "EUR:1",
+ "Give one back.",
+ );
+ await sandbox.makeTransaction(
+ "mock-account-2",
+ "mock-account",
+ "EUR:0.11",
+ "Give fraction back.",
+ );
+ let ret = await LibeufinSandboxApi.getAccountInfoWithBalance(
+ sandbox,
+ "mock-account-2",
+ );
+ console.log(ret.data.balance);
+ t.assertTrue(ret.data.balance == "EUR:1.89");
+}
+runLibeufinSandboxWireTransferCliTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-tutorial.ts b/packages/taler-harness/src/integrationtests/test-libeufin-tutorial.ts
new file mode 100644
index 000000000..7bc067cfe
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-tutorial.ts
@@ -0,0 +1,128 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ LibeufinNexusService,
+ LibeufinSandboxService,
+ LibeufinCli,
+} from "../harness/libeufin.js";
+
+/**
+ * Run basic test with LibEuFin.
+ */
+export async function runLibeufinTutorialTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const libeufinSandbox = await LibeufinSandboxService.create(t, {
+ httpPort: 5010,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
+ });
+
+ await libeufinSandbox.start();
+ await libeufinSandbox.pingUntilAvailable();
+
+ const libeufinNexus = await LibeufinNexusService.create(t, {
+ httpPort: 5011,
+ databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
+ });
+
+ const nexusUser = { username: "foo", password: "secret" };
+ const libeufinCli = new LibeufinCli(t, {
+ sandboxUrl: libeufinSandbox.baseUrl,
+ nexusUrl: libeufinNexus.baseUrl,
+ sandboxDatabaseUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
+ nexusDatabaseUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
+ nexusUser: nexusUser,
+ });
+
+ const ebicsDetails = {
+ hostId: "testhost",
+ partnerId: "partner01",
+ userId: "user01",
+ };
+ const bankAccountDetails = {
+ currency: "EUR",
+ iban: "DE18500105172929531888",
+ bic: "INGDDEFFXXX",
+ personName: "Jane Normal",
+ accountName: "testacct01",
+ };
+
+ await libeufinCli.checkSandbox();
+ await libeufinCli.createEbicsHost("testhost");
+ await libeufinCli.createEbicsSubscriber(ebicsDetails);
+ await libeufinCli.createEbicsBankAccount(ebicsDetails, bankAccountDetails);
+ await libeufinCli.generateTransactions(bankAccountDetails.accountName);
+
+ await libeufinNexus.start();
+ await libeufinNexus.pingUntilAvailable();
+
+ await libeufinNexus.createNexusSuperuser(nexusUser);
+ const connectionDetails = {
+ subscriberDetails: ebicsDetails,
+ ebicsUrl: `${libeufinSandbox.baseUrl}ebicsweb`, // FIXME: need appropriate URL concatenation
+ connectionName: "my-ebics-conn",
+ };
+ await libeufinCli.createEbicsConnection(connectionDetails);
+ await libeufinCli.createBackupFile({
+ passphrase: "secret",
+ outputFile: `${t.testDir}/connection-backup.json`,
+ connectionName: connectionDetails.connectionName,
+ });
+ await libeufinCli.createKeyLetter({
+ outputFile: `${t.testDir}/letter.pdf`,
+ connectionName: connectionDetails.connectionName,
+ });
+ await libeufinCli.connect(connectionDetails.connectionName);
+ await libeufinCli.downloadBankAccounts(connectionDetails.connectionName);
+ await libeufinCli.listOfferedBankAccounts(connectionDetails.connectionName);
+
+ const bankAccountImportDetails = {
+ offeredBankAccountName: bankAccountDetails.accountName,
+ nexusBankAccountName: "at-nexus-testacct01",
+ connectionName: connectionDetails.connectionName,
+ };
+
+ await libeufinCli.importBankAccount(bankAccountImportDetails);
+ await libeufinSandbox.c53tick()
+ await libeufinCli.fetchTransactions(bankAccountImportDetails.nexusBankAccountName);
+ await libeufinCli.transactions(bankAccountImportDetails.nexusBankAccountName);
+
+ const paymentDetails = {
+ creditorIban: "DE42500105171245624648",
+ creditorBic: "BELADEBEXXX",
+ creditorName: "Mina Musterfrau",
+ subject: "Purchase 01234",
+ amount: "1.0",
+ currency: "EUR",
+ nexusBankAccountName: bankAccountImportDetails.nexusBankAccountName,
+ };
+ await libeufinCli.preparePayment(paymentDetails);
+ await libeufinCli.submitPayment(paymentDetails, "1");
+
+ await libeufinCli.newTalerWireGatewayFacade({
+ accountName: bankAccountImportDetails.nexusBankAccountName,
+ connectionName: "my-ebics-conn",
+ currency: "EUR",
+ facadeName: "my-twg",
+ });
+ await libeufinCli.listFacades();
+}
+runLibeufinTutorialTest.suites = ["libeufin"];
diff --git a/packages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts b/packages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts
new file mode 100644
index 000000000..30ab1cd4b
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts
@@ -0,0 +1,243 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ codecForMerchantOrderStatusUnpaid,
+ ConfirmPayResultType,
+ PreparePayResultType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import axiosImp from "axios";
+const axios = axiosImp.default;
+import { URL } from "url";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectedMerchantService,
+} from "../harness/faultInjection.js";
+import {
+ BankService,
+ ExchangeService,
+ getPayto,
+ GlobalTestState,
+ MerchantPrivateApi,
+ MerchantService,
+ setupDb,
+ WalletCli,
+} from "../harness/harness.js";
+import {
+ FaultyMerchantTestEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ */
+export async function createConfusedMerchantTestkudosEnvironment(
+ t: GlobalTestState,
+): Promise<FaultyMerchantTestEnvironment> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const faultyMerchant = new FaultInjectedMerchantService(t, merchant, 9083);
+ const faultyExchange = new FaultInjectedExchangeService(t, exchange, 9081);
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(
+ faultyExchange,
+ exchangeBankAccount.accountPaytoUri,
+ );
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ // Confuse the merchant by adding the non-proxied exchange.
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ });
+
+ await merchant.addInstance({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ wallet,
+ bank,
+ exchangeBankAccount,
+ faultyMerchant,
+ faultyExchange,
+ };
+}
+
+/**
+ * Confuse the merchant by having one URL for the same exchange in the config,
+ * but sending coins from the same exchange with a different URL.
+ */
+export async function runMerchantExchangeConfusionTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, faultyExchange, faultyMerchant } =
+ await createConfusedMerchantTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange: faultyExchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ /**
+ * =========================================================================
+ * Create an order and let the wallet pay under a session ID
+ *
+ * We check along the way that the JSON response to /orders/{order_id}
+ * returns the right thing.
+ * =========================================================================
+ */
+
+ const merchant = faultyMerchant;
+
+ let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-one",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ let publicOrderStatusUrl = orderStatus.order_status_url;
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ const proposalId = preparePayResp.proposalId;
+
+ const orderUrlWithHash = new URL(publicOrderStatusUrl);
+ orderUrlWithHash.searchParams.set(
+ "h_contract",
+ preparePayResp.contractTermsHash,
+ );
+
+ console.log("requesting", orderUrlWithHash.href);
+
+ publicOrderStatusResp = await axios.get(orderUrlWithHash.href, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ const confirmPayRes = await wallet.client.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId: proposalId,
+ },
+ );
+
+ t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+}
+
+runMerchantExchangeConfusionTest.suites = ["merchant"];
diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instances-delete.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances-delete.ts
new file mode 100644
index 000000000..09231cdd8
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-instances-delete.ts
@@ -0,0 +1,129 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { URL } from "@gnu-taler/taler-util";
+import axiosImp from "axios";
+const axios = axiosImp.default;
+import {
+ ExchangeService,
+ GlobalTestState,
+ MerchantApiClient,
+ MerchantService,
+ setupDb,
+ getPayto,
+} from "../harness/harness.js";
+
+/**
+ * Test instance deletion and authentication for it
+ */
+export async function runMerchantInstancesDeleteTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ // We add the exchange to the config, but note that the exchange won't be started.
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ // Base URL for the default instance.
+ const baseUrl = merchant.makeInstanceBaseUrl();
+
+ {
+ const r = await axios.get(new URL("config", baseUrl).href);
+ console.log(r.data);
+ t.assertDeepEqual(r.data.currency, "TESTKUDOS");
+ }
+
+ // Instances should initially be empty
+ {
+ const r = await axios.get(new URL("management/instances", baseUrl).href);
+ t.assertDeepEqual(r.data.instances, []);
+ }
+
+ // Add an instance, no auth!
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+
+ // Add an instance, no auth!
+ await merchant.addInstance({
+ id: "myinst",
+ name: "Second Instance",
+ paytoUris: [getPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+
+ let merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
+ method: "external",
+ });
+
+ await merchantClient.changeAuth({
+ method: "token",
+ token: "secret-token:foobar",
+ });
+
+ merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
+ method: "token",
+ token: "secret-token:foobar",
+ });
+
+ // Check that deleting an instance checks the auth
+ // of the default instance.
+ {
+ const unauthMerchantClient = new MerchantApiClient(
+ merchant.makeInstanceBaseUrl(),
+ {
+ method: "token",
+ token: "secret-token:invalid",
+ },
+ );
+
+ const exc = await t.assertThrowsAsync(async () => {
+ await unauthMerchantClient.deleteInstance("myinst");
+ });
+ console.log("Got expected exception", exc);
+ t.assertAxiosError(exc);
+ t.assertDeepEqual(exc.response?.status, 401);
+ }
+}
+
+runMerchantInstancesDeleteTest.suites = ["merchant"];
diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts
new file mode 100644
index 000000000..a4e44c7f3
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts
@@ -0,0 +1,189 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { Duration } from "@gnu-taler/taler-util";
+import axiosImp from "axios";
+const axios = axiosImp.default;
+import {
+ ExchangeService,
+ GlobalTestState,
+ MerchantApiClient,
+ MerchantService,
+ setupDb,
+ getPayto,
+} from "../harness/harness.js";
+
+/**
+ * Do basic checks on instance management and authentication.
+ */
+export async function runMerchantInstancesUrlsTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ const clientForDefault = new MerchantApiClient(
+ merchant.makeInstanceBaseUrl(),
+ {
+ method: "token",
+ token: "secret-token:i-am-default",
+ },
+ );
+
+ await clientForDefault.createInstance({
+ id: "default",
+ address: {},
+ default_max_deposit_fee: "TESTKUDOS:1",
+ default_max_wire_fee: "TESTKUDOS:1",
+ default_pay_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
+ default_wire_fee_amortization: 1,
+ default_wire_transfer_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
+ jurisdiction: {},
+ name: "My Default Instance",
+ payto_uris: [getPayto("bar")],
+ auth: {
+ method: "token",
+ token: "secret-token:i-am-default",
+ },
+ });
+
+ await clientForDefault.createInstance({
+ id: "myinst",
+ address: {},
+ default_max_deposit_fee: "TESTKUDOS:1",
+ default_max_wire_fee: "TESTKUDOS:1",
+ default_pay_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
+ default_wire_fee_amortization: 1,
+ default_wire_transfer_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
+ jurisdiction: {},
+ name: "My Second Instance",
+ payto_uris: [getPayto("bar")],
+ auth: {
+ method: "token",
+ token: "secret-token:i-am-myinst",
+ },
+ });
+
+ async function check(url: string, token: string, expectedStatus: number) {
+ const resp = await axios.get(url, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ validateStatus: () => true,
+ });
+ console.log(
+ `checking ${url}, expected ${expectedStatus}, got ${resp.status}`,
+ );
+ t.assertDeepEqual(resp.status, expectedStatus);
+ }
+
+ const tokDefault = "secret-token:i-am-default";
+
+ const defaultBaseUrl = merchant.makeInstanceBaseUrl();
+
+ await check(
+ `${defaultBaseUrl}private/instances/default/instances/default/config`,
+ tokDefault,
+ 404,
+ );
+
+ // Instance management is only available when accessing the default instance
+ // directly.
+ await check(
+ `${defaultBaseUrl}instances/default/private/instances`,
+ "foo",
+ 404,
+ );
+
+ // Non-default instances don't allow instance management.
+ await check(`${defaultBaseUrl}instances/foo/private/instances`, "foo", 404);
+ await check(
+ `${defaultBaseUrl}instances/myinst/private/instances`,
+ "foo",
+ 404,
+ );
+
+ await check(`${defaultBaseUrl}config`, "foo", 200);
+ await check(`${defaultBaseUrl}instances/default/config`, "foo", 200);
+ await check(`${defaultBaseUrl}instances/myinst/config`, "foo", 200);
+ await check(`${defaultBaseUrl}instances/foo/config`, "foo", 404);
+ await check(
+ `${defaultBaseUrl}instances/default/instances/config`,
+ "foo",
+ 404,
+ );
+
+ await check(
+ `${defaultBaseUrl}private/instances/myinst/config`,
+ tokDefault,
+ 404,
+ );
+
+ await check(
+ `${defaultBaseUrl}instances/myinst/private/orders`,
+ tokDefault,
+ 401,
+ );
+
+ await check(
+ `${defaultBaseUrl}instances/myinst/private/orders`,
+ tokDefault,
+ 401,
+ );
+
+ await check(
+ `${defaultBaseUrl}instances/myinst/private/orders`,
+ "secret-token:i-am-myinst",
+ 200,
+ );
+
+ await check(
+ `${defaultBaseUrl}private/instances/myinst/orders`,
+ tokDefault,
+ 404,
+ );
+}
+
+runMerchantInstancesUrlsTest.suites = ["merchant"];
diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instances.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances.ts
new file mode 100644
index 000000000..3efe83241
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-instances.ts
@@ -0,0 +1,184 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { URL } from "@gnu-taler/taler-util";
+import axiosImp from "axios";
+const axios = axiosImp.default;
+import {
+ ExchangeService,
+ GlobalTestState,
+ MerchantApiClient,
+ MerchantService,
+ setupDb,
+ getPayto
+} from "../harness/harness.js";
+
+/**
+ * Do basic checks on instance management and authentication.
+ */
+export async function runMerchantInstancesTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ // We add the exchange to the config, but note that the exchange won't be started.
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ // Base URL for the default instance.
+ const baseUrl = merchant.makeInstanceBaseUrl();
+
+ {
+ const r = await axios.get(new URL("config", baseUrl).href);
+ console.log(r.data);
+ t.assertDeepEqual(r.data.currency, "TESTKUDOS");
+ }
+
+ // Instances should initially be empty
+ {
+ const r = await axios.get(new URL("management/instances", baseUrl).href);
+ t.assertDeepEqual(r.data.instances, []);
+ }
+
+ // Add an instance, no auth!
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+
+ // Add an instance, no auth!
+ await merchant.addInstance({
+ id: "myinst",
+ name: "Second Instance",
+ paytoUris: [getPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+
+ let merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
+ method: "external",
+ });
+
+ {
+ const r = await merchantClient.getInstances();
+ t.assertDeepEqual(r.instances.length, 2);
+ }
+
+ // Check that a "malformed" bearer Authorization header gets ignored
+ {
+ const url = merchant.makeInstanceBaseUrl();
+ const resp = await axios.get(new URL("management/instances", url).href, {
+ headers: {
+ Authorization: "foo bar-baz",
+ },
+ });
+ t.assertDeepEqual(resp.status, 200);
+ }
+
+ {
+ const fullDetails = await merchantClient.getInstanceFullDetails("default");
+ t.assertDeepEqual(fullDetails.auth.method, "external");
+ }
+
+ await merchantClient.changeAuth({
+ method: "token",
+ token: "secret-token:foobar",
+ });
+
+ // Now this should fail, as we didn't change the auth of the client yet.
+ const exc = await t.assertThrowsAsync(async () => {
+ console.log("requesting instances with auth", merchantClient.auth);
+ const resp = await merchantClient.getInstances();
+ console.log("instances result:", resp);
+ });
+
+ console.log(exc);
+
+ t.assertAxiosError(exc);
+ t.assertTrue(exc.response?.status === 401);
+
+ merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
+ method: "token",
+ token: "secret-token:foobar",
+ });
+
+ // With the new client auth settings, request should work again.
+ await merchantClient.getInstances();
+
+ // Now, try some variations.
+ {
+ const url = merchant.makeInstanceBaseUrl();
+ const resp = await axios.get(new URL("management/instances", url).href, {
+ headers: {
+ // Note the spaces
+ Authorization: "Bearer secret-token:foobar",
+ },
+ });
+ t.assertDeepEqual(resp.status, 200);
+ }
+
+ // Check that auth is reported properly
+ {
+ const fullDetails = await merchantClient.getInstanceFullDetails("default");
+ t.assertDeepEqual(fullDetails.auth.method, "token");
+ // Token should *not* be reported back.
+ t.assertDeepEqual(fullDetails.auth.token, undefined);
+ }
+
+ // Check that deleting an instance checks the auth
+ // of the default instance.
+ {
+ const unauthMerchantClient = new MerchantApiClient(
+ merchant.makeInstanceBaseUrl(),
+ {
+ method: "external",
+ },
+ );
+
+ const exc = await t.assertThrowsAsync(async () => {
+ await unauthMerchantClient.deleteInstance("myinst");
+ });
+ console.log(exc);
+ t.assertAxiosError(exc);
+ t.assertDeepEqual(exc.response?.status, 401);
+ }
+}
+
+runMerchantInstancesTest.suites = ["merchant"];
diff --git a/packages/taler-harness/src/integrationtests/test-merchant-longpolling.ts b/packages/taler-harness/src/integrationtests/test-merchant-longpolling.ts
new file mode 100644
index 000000000..4b9f53f05
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-longpolling.ts
@@ -0,0 +1,162 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
+import {
+ PreparePayResultType,
+ codecForMerchantOrderStatusUnpaid,
+ ConfirmPayResultType,
+ URL,
+} from "@gnu-taler/taler-util";
+import axiosImp from "axios";
+const axios = axiosImp.default;
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runMerchantLongpollingTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ /**
+ * =========================================================================
+ * Create an order and let the wallet pay under a session ID
+ *
+ * We check along the way that the JSON response to /orders/{order_id}
+ * returns the right thing.
+ * =========================================================================
+ */
+
+ let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ },
+ create_token: false,
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-one",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ let publicOrderStatusUrl = new URL(orderStatus.order_status_url);
+
+ // First, request order status without longpolling
+ {
+ console.log("requesting", publicOrderStatusUrl.href);
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming, no long polling), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+ }
+
+ // Now do long-polling for half a second!
+ publicOrderStatusUrl.searchParams.set("timeout_ms", "500");
+
+ console.log("requesting", publicOrderStatusUrl.href);
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming, with long-polling), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ console.log(pubUnpaidStatus);
+
+ /**
+ * =========================================================================
+ * Now actually pay, but WHILE a long poll is active!
+ * =========================================================================
+ */
+
+ let preparePayResp = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ publicOrderStatusUrl.searchParams.set("timeout_ms", "5000");
+ publicOrderStatusUrl.searchParams.set(
+ "h_contract",
+ preparePayResp.contractTermsHash,
+ );
+
+ let publicOrderStatusPromise = axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ const proposalId = preparePayResp.proposalId;
+
+ publicOrderStatusResp = await publicOrderStatusPromise;
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ const confirmPayRes = await wallet.client.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId: proposalId,
+ },
+ );
+
+ t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+}
+
+runMerchantLongpollingTest.suites = ["merchant"];
diff --git a/packages/taler-harness/src/integrationtests/test-merchant-refund-api.ts b/packages/taler-harness/src/integrationtests/test-merchant-refund-api.ts
new file mode 100644
index 000000000..5d9b23fa7
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-refund-api.ts
@@ -0,0 +1,303 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ GlobalTestState,
+ MerchantPrivateApi,
+ MerchantServiceInterface,
+ WalletCli,
+ ExchangeServiceInterface,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+import {
+ URL,
+ durationFromSpec,
+ PreparePayResultType,
+ Duration,
+} from "@gnu-taler/taler-util";
+import axiosImp from "axios";
+const axios = axiosImp.default;
+import {
+ WalletApiOperation,
+ BankServiceHandle,
+} from "@gnu-taler/taler-wallet-core";
+
+async function testRefundApiWithFulfillmentUrl(
+ t: GlobalTestState,
+ env: {
+ merchant: MerchantServiceInterface;
+ bank: BankServiceHandle;
+ wallet: WalletCli;
+ exchange: ExchangeServiceInterface;
+ },
+): Promise<void> {
+ const { wallet, bank, exchange, merchant } = env;
+
+ // Set up order.
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/fulfillment",
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ durationFromSpec({ minutes: 5 }),
+ ),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUri = orderStatus.taler_pay_uri;
+ const orderId = orderResp.order_id;
+
+ // Make wallet pay for the order
+
+ let preparePayResult = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ proposalId: preparePayResult.proposalId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ preparePayResult = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.AlreadyConfirmed,
+ );
+
+ await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ t.assertAmountEquals(orderStatus.refund_amount, "TESTKUDOS:5");
+
+ // Now test what the merchant gives as a response for various requests to the
+ // public order status URL!
+
+ let publicOrderStatusUrl = new URL(
+ `orders/${orderId}`,
+ merchant.makeInstanceBaseUrl(),
+ );
+ publicOrderStatusUrl.searchParams.set(
+ "h_contract",
+ preparePayResult.contractTermsHash,
+ );
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+ console.log(publicOrderStatusResp.data);
+ t.assertTrue(publicOrderStatusResp.status === 200);
+ t.assertAmountEquals(publicOrderStatusResp.data.refund_amount, "TESTKUDOS:5");
+
+ publicOrderStatusUrl = new URL(
+ `orders/${orderId}`,
+ merchant.makeInstanceBaseUrl(),
+ );
+ console.log(`requesting order status via '${publicOrderStatusUrl.href}'`);
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+ console.log(publicOrderStatusResp.status);
+ console.log(publicOrderStatusResp.data);
+ // We didn't give any authentication, so we should get a fulfillment URL back
+ t.assertTrue(publicOrderStatusResp.status === 403);
+}
+
+async function testRefundApiWithFulfillmentMessage(
+ t: GlobalTestState,
+ env: {
+ merchant: MerchantServiceInterface;
+ bank: BankServiceHandle;
+ wallet: WalletCli;
+ exchange: ExchangeServiceInterface;
+ },
+): Promise<void> {
+ const { wallet, bank, exchange, merchant } = env;
+
+ // Set up order.
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_message: "Thank you for buying foobar",
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ durationFromSpec({ minutes: 5 }),
+ ),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUri = orderStatus.taler_pay_uri;
+ const orderId = orderResp.order_id;
+
+ // Make wallet pay for the order
+
+ let preparePayResult = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ proposalId: preparePayResult.proposalId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ preparePayResult = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.AlreadyConfirmed,
+ );
+
+ await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ t.assertAmountEquals(orderStatus.refund_amount, "TESTKUDOS:5");
+
+ // Now test what the merchant gives as a response for various requests to the
+ // public order status URL!
+
+ let publicOrderStatusUrl = new URL(
+ `orders/${orderId}`,
+ merchant.makeInstanceBaseUrl(),
+ );
+ publicOrderStatusUrl.searchParams.set(
+ "h_contract",
+ preparePayResult.contractTermsHash,
+ );
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+ console.log(publicOrderStatusResp.data);
+ t.assertTrue(publicOrderStatusResp.status === 200);
+ t.assertAmountEquals(publicOrderStatusResp.data.refund_amount, "TESTKUDOS:5");
+
+ publicOrderStatusUrl = new URL(
+ `orders/${orderId}`,
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+ console.log(publicOrderStatusResp.data);
+ // We didn't give any authentication, so we should get a fulfillment URL back
+ t.assertTrue(publicOrderStatusResp.status === 403);
+}
+
+/**
+ * Test case for the refund API of the merchant backend.
+ */
+export async function runMerchantRefundApiTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ await testRefundApiWithFulfillmentUrl(t, {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ });
+
+ await testRefundApiWithFulfillmentMessage(t, {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ });
+}
+
+runMerchantRefundApiTest.suites = ["merchant"];
diff --git a/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts b/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts
new file mode 100644
index 000000000..70edaaf0c
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts
@@ -0,0 +1,620 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ ConfirmPayResultType,
+ PreparePayResultType,
+ URL,
+ encodeCrock,
+ getRandomBytes,
+} from "@gnu-taler/taler-util";
+import { NodeHttpLib, WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantPrivateApi,
+ MerchantService,
+ WalletCli,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+
+const httpLib = new NodeHttpLib();
+
+interface Context {
+ merchant: MerchantService;
+ merchantBaseUrl: string;
+ bank: BankService;
+ exchange: ExchangeService;
+}
+
+async function testWithClaimToken(
+ t: GlobalTestState,
+ c: Context,
+): Promise<void> {
+ const wallet = new WalletCli(t, "withclaimtoken");
+ const { bank, exchange } = c;
+ const { merchant, merchantBaseUrl } = c;
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const sessionId = "mysession";
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ });
+
+ const claimToken = orderResp.token;
+ const orderId = orderResp.order_id;
+ t.assertTrue(!!claimToken);
+ let talerPayUri: string;
+
+ {
+ const httpResp = await httpLib.get(
+ new URL(`orders/${orderId}`, merchantBaseUrl).href,
+ );
+ const r = await httpResp.json();
+ t.assertDeepEqual(httpResp.status, 202);
+ console.log(r);
+ }
+
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ url.searchParams.set("token", claimToken);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ t.assertDeepEqual(httpResp.status, 402);
+ console.log(r);
+ talerPayUri = r.taler_pay_uri;
+ t.assertTrue(!!talerPayUri);
+ }
+
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ url.searchParams.set("token", claimToken);
+ const httpResp = await httpLib.get(url.href, {
+ headers: {
+ Accept: "text/html",
+ },
+ });
+ const r = await httpResp.text();
+ t.assertDeepEqual(httpResp.status, 402);
+ console.log(r);
+ }
+
+ const preparePayResp = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+ const contractTermsHash = preparePayResp.contractTermsHash;
+ const proposalId = preparePayResp.proposalId;
+
+ // claimed, unpaid, access with wrong h_contract
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const hcWrong = encodeCrock(getRandomBytes(64));
+ url.searchParams.set("h_contract", hcWrong);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 403);
+ }
+
+ // claimed, unpaid, access with wrong claim token
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const ctWrong = encodeCrock(getRandomBytes(16));
+ url.searchParams.set("token", ctWrong);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 403);
+ }
+
+ // claimed, unpaid, access with correct claim token
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ url.searchParams.set("token", claimToken);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 402);
+ }
+
+ // claimed, unpaid, access with correct contract terms hash
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ url.searchParams.set("h_contract", contractTermsHash);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 402);
+ }
+
+ // claimed, unpaid, access without credentials
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 202);
+ }
+
+ const confirmPayRes = await wallet.client.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId: proposalId,
+ },
+ );
+
+ t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+
+ // paid, access without credentials
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 202);
+ }
+
+ // paid, access with wrong h_contract
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const hcWrong = encodeCrock(getRandomBytes(64));
+ url.searchParams.set("h_contract", hcWrong);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 403);
+ }
+
+ // paid, access with wrong claim token
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const ctWrong = encodeCrock(getRandomBytes(16));
+ url.searchParams.set("token", ctWrong);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 403);
+ }
+
+ // paid, access with correct h_contract
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ url.searchParams.set("h_contract", contractTermsHash);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 200);
+ }
+
+ // paid, access with correct claim token, JSON
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ url.searchParams.set("token", claimToken);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 200);
+ const respFulfillmentUrl = r.fulfillment_url;
+ t.assertDeepEqual(respFulfillmentUrl, "https://example.com/article42");
+ }
+
+ // paid, access with correct claim token, HTML
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ url.searchParams.set("token", claimToken);
+ const httpResp = await httpLib.get(url.href, {
+ headers: { Accept: "text/html" },
+ });
+ t.assertDeepEqual(httpResp.status, 200);
+ }
+
+ const confirmPayRes2 = await wallet.client.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId: proposalId,
+ sessionId: sessionId,
+ },
+ );
+
+ t.assertTrue(confirmPayRes2.type === ConfirmPayResultType.Done);
+
+ // Create another order with identical fulfillment URL to test the "already paid" flow
+ const alreadyPaidOrderResp = await MerchantPrivateApi.createOrder(
+ merchant,
+ "default",
+ {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ },
+ );
+
+ const apOrderId = alreadyPaidOrderResp.order_id;
+ const apToken = alreadyPaidOrderResp.token;
+ t.assertTrue(!!apToken);
+
+ {
+ const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
+ url.searchParams.set("token", apToken);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 402);
+ }
+
+ // Check for already paid session ID, JSON
+ {
+ const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
+ url.searchParams.set("token", apToken);
+ url.searchParams.set("session_id", sessionId);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 402);
+ const alreadyPaidOrderId = r.already_paid_order_id;
+ t.assertDeepEqual(alreadyPaidOrderId, orderId);
+ }
+
+ // Check for already paid session ID, HTML
+ {
+ const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
+ url.searchParams.set("token", apToken);
+ url.searchParams.set("session_id", sessionId);
+ const httpResp = await httpLib.get(url.href, {
+ headers: { Accept: "text/html" },
+ });
+ t.assertDeepEqual(httpResp.status, 302);
+ const location = httpResp.headers.get("Location");
+ console.log("location header:", location);
+ t.assertDeepEqual(location, "https://example.com/article42");
+ }
+}
+
+async function testWithoutClaimToken(
+ t: GlobalTestState,
+ c: Context,
+): Promise<void> {
+ const wallet = new WalletCli(t, "withoutct");
+ const sessionId = "mysession2";
+ const { bank, exchange } = c;
+ const { merchant, merchantBaseUrl } = c;
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ create_token: false,
+ });
+
+ const orderId = orderResp.order_id;
+ let talerPayUri: string;
+
+ {
+ const httpResp = await httpLib.get(
+ new URL(`orders/${orderId}`, merchantBaseUrl).href,
+ );
+ const r = await httpResp.json();
+ t.assertDeepEqual(httpResp.status, 402);
+ console.log(r);
+ }
+
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ t.assertDeepEqual(httpResp.status, 402);
+ console.log(r);
+ talerPayUri = r.taler_pay_uri;
+ t.assertTrue(!!talerPayUri);
+ }
+
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const httpResp = await httpLib.get(url.href, {
+ headers: {
+ Accept: "text/html",
+ },
+ });
+ const r = await httpResp.text();
+ t.assertDeepEqual(httpResp.status, 402);
+ console.log(r);
+ }
+
+ const preparePayResp = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ console.log(preparePayResp);
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+ const contractTermsHash = preparePayResp.contractTermsHash;
+ const proposalId = preparePayResp.proposalId;
+
+ // claimed, unpaid, access with wrong h_contract
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const hcWrong = encodeCrock(getRandomBytes(64));
+ url.searchParams.set("h_contract", hcWrong);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 403);
+ }
+
+ // claimed, unpaid, access with wrong claim token
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const ctWrong = encodeCrock(getRandomBytes(16));
+ url.searchParams.set("token", ctWrong);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 403);
+ }
+
+ // claimed, unpaid, no claim token
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 402);
+ }
+
+ // claimed, unpaid, access with correct contract terms hash
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ url.searchParams.set("h_contract", contractTermsHash);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 402);
+ }
+
+ // claimed, unpaid, access without credentials
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ // No credentials, but the order doesn't require a claim token.
+ // This effectively means that the order ID is already considered
+ // enough authentication, at least to check for the basic order status
+ t.assertDeepEqual(httpResp.status, 402);
+ }
+
+ const confirmPayRes = await wallet.client.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId: proposalId,
+ },
+ );
+
+ t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+
+ // paid, access without credentials
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 200);
+ }
+
+ // paid, access with wrong h_contract
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const hcWrong = encodeCrock(getRandomBytes(64));
+ url.searchParams.set("h_contract", hcWrong);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 403);
+ }
+
+ // paid, access with wrong claim token
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const ctWrong = encodeCrock(getRandomBytes(16));
+ url.searchParams.set("token", ctWrong);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 403);
+ }
+
+ // paid, access with correct h_contract
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ url.searchParams.set("h_contract", contractTermsHash);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 200);
+ }
+
+ // paid, JSON
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 200);
+ const respFulfillmentUrl = r.fulfillment_url;
+ t.assertDeepEqual(respFulfillmentUrl, "https://example.com/article42");
+ }
+
+ // paid, HTML
+ {
+ const url = new URL(`orders/${orderId}`, merchantBaseUrl);
+ const httpResp = await httpLib.get(url.href, {
+ headers: { Accept: "text/html" },
+ });
+ t.assertDeepEqual(httpResp.status, 200);
+ }
+
+ const confirmPayRes2 = await wallet.client.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId: proposalId,
+ sessionId: sessionId,
+ },
+ );
+
+ t.assertTrue(confirmPayRes2.type === ConfirmPayResultType.Done);
+
+ // Create another order with identical fulfillment URL to test the "already paid" flow
+ const alreadyPaidOrderResp = await MerchantPrivateApi.createOrder(
+ merchant,
+ "default",
+ {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ },
+ );
+
+ const apOrderId = alreadyPaidOrderResp.order_id;
+ const apToken = alreadyPaidOrderResp.token;
+ t.assertTrue(!!apToken);
+
+ {
+ const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
+ url.searchParams.set("token", apToken);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 402);
+ }
+
+ // Check for already paid session ID, JSON
+ {
+ const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
+ url.searchParams.set("token", apToken);
+ url.searchParams.set("session_id", sessionId);
+ const httpResp = await httpLib.get(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 402);
+ const alreadyPaidOrderId = r.already_paid_order_id;
+ t.assertDeepEqual(alreadyPaidOrderId, orderId);
+ }
+
+ // Check for already paid session ID, HTML
+ {
+ const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
+ url.searchParams.set("token", apToken);
+ url.searchParams.set("session_id", sessionId);
+ const httpResp = await httpLib.get(url.href, {
+ headers: { Accept: "text/html" },
+ });
+ t.assertDeepEqual(httpResp.status, 302);
+ const location = httpResp.headers.get("Location");
+ console.log("location header:", location);
+ t.assertDeepEqual(location, "https://example.com/article42");
+ }
+}
+
+/**
+ * Checks for the /orders/{id} endpoint of the merchant.
+ *
+ * The tests here should exercise all code paths in the executable
+ * specification of the endpoint.
+ */
+export async function runMerchantSpecPublicOrdersTest(t: GlobalTestState) {
+ const { bank, exchange, merchant } = await createSimpleTestkudosEnvironment(
+ t,
+ );
+
+ // Base URL for the default instance.
+ const merchantBaseUrl = merchant.makeInstanceBaseUrl();
+
+ {
+ const httpResp = await httpLib.get(new URL("config", merchantBaseUrl).href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(r.currency, "TESTKUDOS");
+ }
+
+ {
+ const httpResp = await httpLib.get(
+ new URL("orders/foo", merchantBaseUrl).href,
+ );
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 404);
+ // FIXME: also check Taler error code
+ }
+
+ {
+ const httpResp = await httpLib.get(
+ new URL("orders/foo", merchantBaseUrl).href,
+ {
+ headers: {
+ Accept: "text/html",
+ },
+ },
+ );
+ const r = await httpResp.text();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 404);
+ // FIXME: also check Taler error code
+ }
+
+ await testWithClaimToken(t, {
+ merchant,
+ merchantBaseUrl,
+ exchange,
+ bank,
+ });
+
+ await testWithoutClaimToken(t, {
+ merchant,
+ merchantBaseUrl,
+ exchange,
+ bank,
+ });
+}
+
+runMerchantSpecPublicOrdersTest.suites = ["merchant"];
diff --git a/packages/taler-harness/src/integrationtests/test-pay-paid.ts b/packages/taler-harness/src/integrationtests/test-pay-paid.ts
new file mode 100644
index 000000000..2ef91e4a8
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-pay-paid.ts
@@ -0,0 +1,222 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
+import {
+ withdrawViaBank,
+ createFaultInjectedMerchantTestkudosEnvironment,
+} from "../harness/helpers.js";
+import {
+ PreparePayResultType,
+ codecForMerchantOrderStatusUnpaid,
+ ConfirmPayResultType,
+ URL,
+} from "@gnu-taler/taler-util";
+import axiosImp from "axios";
+const axios = axiosImp.default;
+import { FaultInjectionRequestContext } from "../harness/faultInjection.js";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Run test for the wallets repurchase detection mechanism
+ * based on the fulfillment URL.
+ *
+ * FIXME: This test is now almost the same as test-paywall-flow,
+ * since we can't initiate payment via a "claimed" private order status
+ * response.
+ */
+export async function runPayPaidTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, faultyExchange, faultyMerchant } =
+ await createFaultInjectedMerchantTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange: faultyExchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ /**
+ * =========================================================================
+ * Create an order and let the wallet pay under a session ID
+ *
+ * We check along the way that the JSON response to /orders/{order_id}
+ * returns the right thing.
+ * =========================================================================
+ */
+
+ const merchant = faultyMerchant;
+
+ let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-one",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ let publicOrderStatusUrl = orderStatus.order_status_url;
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ const proposalId = preparePayResp.proposalId;
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ const confirmPayRes = await wallet.client.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId: proposalId,
+ },
+ );
+
+ t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ console.log(publicOrderStatusResp.data);
+
+ if (publicOrderStatusResp.status != 200) {
+ console.log(publicOrderStatusResp.data);
+ throw Error(
+ `expected status 200 (after paying), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ /**
+ * =========================================================================
+ * Now change up the session ID and do payment re-play!
+ * =========================================================================
+ */
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-two",
+ });
+
+ console.log(
+ "order status under mysession-two:",
+ JSON.stringify(orderStatus, undefined, 2),
+ );
+
+ // Should be claimed (not paid!) because of a new session ID
+ t.assertTrue(orderStatus.order_status === "claimed");
+
+ let numPayRequested = 0;
+ let numPaidRequested = 0;
+
+ faultyMerchant.faultProxy.addFault({
+ async modifyRequest(ctx: FaultInjectionRequestContext) {
+ const url = new URL(ctx.requestUrl);
+ if (url.pathname.endsWith("/pay")) {
+ numPayRequested++;
+ } else if (url.pathname.endsWith("/paid")) {
+ numPaidRequested++;
+ }
+ },
+ });
+
+ let orderRespTwo = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ });
+
+ let orderStatusTwo = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderRespTwo.order_id,
+ sessionId: "mysession-two",
+ },
+ );
+
+ t.assertTrue(orderStatusTwo.order_status === "unpaid");
+
+ // Pay with new taler://pay URI, which should
+ // have the new session ID!
+ // Wallet should now automatically re-play payment.
+ preparePayResp = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatusTwo.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.AlreadyConfirmed);
+ t.assertTrue(preparePayResp.paid);
+
+ // Make sure the wallet is actually doing the replay properly.
+ t.assertTrue(numPaidRequested == 1);
+ t.assertTrue(numPayRequested == 0);
+}
+
+runPayPaidTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-payment-claim.ts b/packages/taler-harness/src/integrationtests/test-payment-claim.ts
new file mode 100644
index 000000000..e93d2c44c
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-claim.ts
@@ -0,0 +1,110 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ GlobalTestState,
+ MerchantPrivateApi,
+ WalletCli,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+import { PreparePayResultType } from "@gnu-taler/taler-util";
+import { TalerErrorCode } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runPaymentClaimTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t);
+
+ const walletTwo = new WalletCli(t, "two");
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ // Set up order.
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUri = orderStatus.taler_pay_uri;
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ t.assertThrowsTalerErrorAsync(async () => {
+ await walletTwo.client.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri,
+ });
+ });
+
+ await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ proposalId: preparePayResult.proposalId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ walletTwo.deleteDatabase();
+
+ const err = await t.assertThrowsTalerErrorAsync(async () => {
+ await walletTwo.client.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri,
+ });
+ });
+
+ t.assertTrue(err.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED));
+
+ await t.shutdown();
+}
+
+runPaymentClaimTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-payment-fault.ts b/packages/taler-harness/src/integrationtests/test-payment-fault.ts
new file mode 100644
index 000000000..dea538e35
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-fault.ts
@@ -0,0 +1,222 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Sample fault injection test.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ GlobalTestState,
+ MerchantService,
+ ExchangeService,
+ setupDb,
+ BankService,
+ WalletCli,
+ MerchantPrivateApi,
+ getPayto,
+} from "../harness/harness.js";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectionRequestContext,
+ FaultInjectionResponseContext,
+} from "../harness/faultInjection.js";
+import { CoreApiResponse } from "@gnu-taler/taler-util";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ WalletApiOperation,
+ BankApi,
+ BankAccessApi,
+} from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runPaymentFaultTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ await exchange.addBankAccount("1", exchangeBankAccount);
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
+
+ // Print all requests to the exchange
+ faultyExchange.faultProxy.addFault({
+ async modifyRequest(ctx: FaultInjectionRequestContext) {
+ console.log("got request", ctx);
+ },
+ async modifyResponse(ctx: FaultInjectionResponseContext) {
+ console.log("got response", ctx);
+ },
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ merchant.addExchange(faultyExchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ // Create withdrawal operation
+
+ const user = await BankApi.createRandomBankUser(bank);
+ const wop = await BankAccessApi.createWithdrawalOperation(
+ bank,
+ user,
+ "TESTKUDOS:20",
+ );
+
+ // Hand it to the wallet
+
+ await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+
+ await wallet.runPending();
+
+ // Withdraw
+
+ await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, {
+ exchangeBaseUrl: faultyExchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+ await wallet.runPending();
+
+ // Confirm it
+
+ await BankApi.confirmWithdrawalOperation(bank, user, wop);
+
+ await wallet.runUntilDone();
+
+ // Check balance
+
+ await wallet.client.call(WalletApiOperation.GetBalances, {});
+
+ // Set up order.
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ let apiResp: CoreApiResponse;
+
+ const prepResp = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ const proposalId = prepResp.proposalId;
+
+ await wallet.runPending();
+
+ // Drop 3 responses from the exchange.
+ let faultCount = 0;
+ faultyExchange.faultProxy.addFault({
+ async modifyResponse(ctx: FaultInjectionResponseContext) {
+ if (!ctx.request.requestUrl.endsWith("/deposit")) {
+ return;
+ }
+ if (faultCount < 3) {
+ console.log(`blocking /deposit request #${faultCount}`);
+ faultCount++;
+ ctx.dropResponse = true;
+ } else {
+ console.log(`letting through /deposit request #${faultCount}`);
+ }
+ },
+ });
+
+ // confirmPay won't work, as the exchange is unreachable
+
+ await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ // FIXME: should be validated, don't cast!
+ proposalId: proposalId,
+ });
+
+ await wallet.runUntilDone();
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+}
+
+runPaymentFaultTest.suites = ["wallet"];
+runPaymentFaultTest.timeoutMs = 120000;
diff --git a/packages/taler-harness/src/integrationtests/test-payment-forgettable.ts b/packages/taler-harness/src/integrationtests/test-payment-forgettable.ts
new file mode 100644
index 000000000..3bdd6bef3
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-forgettable.ts
@@ -0,0 +1,81 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ makeTestPayment,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for payment with a contract that has forgettable fields.
+ */
+export async function runPaymentForgettableTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ {
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ extra: {
+ foo: { bar: "baz" },
+ $forgettable: {
+ foo: "gnu",
+ },
+ },
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+ }
+
+ console.log("testing with forgettable field without hash");
+
+ {
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ extra: {
+ foo: { bar: "baz" },
+ $forgettable: {
+ foo: true,
+ },
+ },
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+ }
+
+ await wallet.runUntilDone();
+}
+
+runPaymentForgettableTest.suites = ["wallet", "merchant"];
diff --git a/packages/taler-harness/src/integrationtests/test-payment-idempotency.ts b/packages/taler-harness/src/integrationtests/test-payment-idempotency.ts
new file mode 100644
index 000000000..1099a8188
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-idempotency.ts
@@ -0,0 +1,121 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+import { PreparePayResultType } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Test the wallet-core payment API, especially that repeated operations
+ * return the expected result.
+ */
+export async function runPaymentIdempotencyTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ // Set up order.
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUri = orderStatus.taler_pay_uri;
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ const preparePayResultRep = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+ t.assertTrue(
+ preparePayResultRep.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const proposalId = preparePayResult.proposalId;
+
+ const confirmPayResult = await wallet.client.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId: proposalId,
+ },
+ );
+
+ console.log("confirm pay result", confirmPayResult);
+
+ await wallet.runUntilDone();
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ const preparePayResultAfter = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ console.log("result after:", preparePayResultAfter);
+
+ t.assertTrue(
+ preparePayResultAfter.status === PreparePayResultType.AlreadyConfirmed,
+ );
+ t.assertTrue(preparePayResultAfter.paid === true);
+
+ await t.shutdown();
+}
+
+runPaymentIdempotencyTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-payment-multiple.ts b/packages/taler-harness/src/integrationtests/test-payment-multiple.ts
new file mode 100644
index 000000000..46325c05f
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-multiple.ts
@@ -0,0 +1,163 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ GlobalTestState,
+ setupDb,
+ BankService,
+ ExchangeService,
+ MerchantService,
+ WalletCli,
+ MerchantPrivateApi,
+ getPayto
+} from "../harness/harness.js";
+import { withdrawViaBank } from "../harness/helpers.js";
+import { coin_ct10, coin_u1 } from "../harness/denomStructures.js";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+
+async function setupTest(
+ t: GlobalTestState,
+): Promise<{
+ merchant: MerchantService;
+ exchange: ExchangeService;
+ bank: BankService;
+}> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+
+ exchange.addOfferedCoins([coin_ct10, coin_u1]);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ await exchange.addBankAccount("1", exchangeBankAccount);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ });
+
+ await merchant.addInstance({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ return {
+ merchant,
+ bank,
+ exchange,
+ };
+}
+
+/**
+ * Run test.
+ *
+ * This test uses a very sub-optimal denomination structure.
+ */
+export async function runPaymentMultipleTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { merchant, bank, exchange } = await setupTest(t);
+
+ const wallet = new WalletCli(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:100" });
+
+ // Set up order.
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:80",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const r1 = await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ // FIXME: should be validated, don't cast!
+ proposalId: r1.proposalId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ await t.shutdown();
+}
+
+runPaymentMultipleTest.suites = ["wallet"];
+runPaymentMultipleTest.timeoutMs = 120000;
diff --git a/packages/taler-harness/src/integrationtests/test-payment-on-demo.ts b/packages/taler-harness/src/integrationtests/test-payment-on-demo.ts
new file mode 100644
index 000000000..737620ce7
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-on-demo.ts
@@ -0,0 +1,114 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, WalletCli } from "../harness/harness.js";
+import { makeTestPayment } from "../harness/helpers.js";
+import {
+ WalletApiOperation,
+ BankApi,
+ BankAccessApi,
+ BankServiceHandle,
+ NodeHttpLib,
+} from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runPaymentDemoTest(t: GlobalTestState) {
+ // Withdraw digital cash into the wallet.
+ let bankInterface: BankServiceHandle = {
+ baseUrl: "https://bank.demo.taler.net/",
+ bankAccessApiBaseUrl: "https://bank.demo.taler.net/",
+ http: new NodeHttpLib(),
+ };
+ let user = await BankApi.createRandomBankUser(bankInterface);
+ let wop = await BankAccessApi.createWithdrawalOperation(
+ bankInterface,
+ user,
+ "KUDOS:20",
+ );
+
+ let wallet = new WalletCli(t);
+ await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+
+ await wallet.runPending();
+
+ // Confirm it
+
+ await BankApi.confirmWithdrawalOperation(bankInterface, user, wop);
+
+ // Withdraw
+
+ await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, {
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+ await wallet.runUntilDone();
+
+ let balanceBefore = await wallet.client.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ t.assertTrue(balanceBefore["balances"].length == 1);
+
+ const order = {
+ summary: "Buy me!",
+ amount: "KUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ let merchant = {
+ makeInstanceBaseUrl: function (instanceName?: string) {
+ return "https://backend.demo.taler.net/instances/donations/";
+ },
+ port: 0,
+ name: "donations",
+ };
+
+ t.assertTrue("TALER_ENV_FRONTENDS_APITOKEN" in process.env);
+
+ await makeTestPayment(
+ t,
+ {
+ merchant,
+ wallet,
+ order,
+ },
+ {
+ Authorization: `Bearer ${process.env["TALER_ENV_FRONTENDS_APITOKEN"]}`,
+ },
+ );
+
+ await wallet.runUntilDone();
+
+ let balanceAfter = await wallet.client.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ t.assertTrue(balanceAfter["balances"].length == 1);
+ t.assertTrue(
+ balanceBefore["balances"][0]["available"] >
+ balanceAfter["balances"][0]["available"],
+ );
+}
+
+runPaymentDemoTest.excludeByDefault = true;
+runPaymentDemoTest.suites = ["buildbot"];
diff --git a/packages/taler-harness/src/integrationtests/test-payment-transient.ts b/packages/taler-harness/src/integrationtests/test-payment-transient.ts
new file mode 100644
index 000000000..b57b355c6
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-transient.ts
@@ -0,0 +1,185 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
+import {
+ withdrawViaBank,
+ createFaultInjectedMerchantTestkudosEnvironment,
+} from "../harness/helpers.js";
+import {
+ FaultInjectionResponseContext,
+} from "../harness/faultInjection.js";
+import {
+ codecForMerchantOrderStatusUnpaid,
+ ConfirmPayResultType,
+ PreparePayResultType,
+ TalerErrorCode,
+ TalerErrorDetail,
+ URL,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import axiosImp from "axios";
+const axios = axiosImp.default;
+
+/**
+ * Run test for a payment where the merchant has a transient
+ * failure in /pay
+ */
+export async function runPaymentTransientTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ faultyMerchant,
+ } = await createFaultInjectedMerchantTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ const merchant = faultyMerchant;
+
+ let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-one",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ let publicOrderStatusUrl = orderStatus.order_status_url;
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+
+
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ const proposalId = preparePayResp.proposalId;
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ let faultInjected = false;
+
+ faultyMerchant.faultProxy.addFault({
+ async modifyResponse(ctx: FaultInjectionResponseContext) {
+ console.log("in modifyResponse");
+ const url = new URL(ctx.request.requestUrl);
+ console.log("pathname is", url.pathname);
+ if (!url.pathname.endsWith("/pay")) {
+ return;
+ }
+ if (faultInjected) {
+ console.log("not injecting pay fault");
+ return;
+ }
+ faultInjected = true;
+ console.log("injecting pay fault");
+ const err: TalerErrorDetail = {
+ code: TalerErrorCode.GENERIC_DB_COMMIT_FAILED,
+ hint: "something went wrong",
+ };
+ ctx.responseBody = Buffer.from(JSON.stringify(err));
+ ctx.statusCode = 500;
+ },
+ });
+
+ const confirmPayResp = await wallet.client.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId,
+ },
+ );
+
+ console.log(confirmPayResp);
+
+ t.assertTrue(confirmPayResp.type === ConfirmPayResultType.Pending);
+ t.assertTrue(faultInjected);
+
+ const confirmPayRespTwo = await wallet.client.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId,
+ },
+ );
+
+ t.assertTrue(confirmPayRespTwo.type === ConfirmPayResultType.Done);
+
+ // Now ask the merchant if paid
+
+ console.log("requesting", publicOrderStatusUrl);
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ console.log(publicOrderStatusResp.data);
+
+ if (publicOrderStatusResp.status != 200) {
+ console.log(publicOrderStatusResp.data);
+ throw Error(
+ `expected status 200 (after paying), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+}
+
+runPaymentTransientTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-payment-zero.ts b/packages/taler-harness/src/integrationtests/test-payment-zero.ts
new file mode 100644
index 000000000..c38b8b382
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-zero.ts
@@ -0,0 +1,72 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ makeTestPayment,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for a payment for a "free" order with
+ * an amount of zero.
+ */
+export async function runPaymentZeroTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ // First, make a "free" payment when we don't even have
+ // any money in the
+
+ // Withdraw digital cash into the wallet.
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ await wallet.runUntilDone();
+
+ await makeTestPayment(t, {
+ wallet,
+ merchant,
+ order: {
+ summary: "I am free!",
+ amount: "TESTKUDOS:0",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ await wallet.runUntilDone();
+
+ const transactions = await wallet.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ for (const tr of transactions.transactions) {
+ t.assertDeepEqual(tr.pending, false);
+ }
+}
+
+runPaymentZeroTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-payment.ts b/packages/taler-harness/src/integrationtests/test-payment.ts
new file mode 100644
index 000000000..66d10f996
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment.ts
@@ -0,0 +1,77 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ makeTestPayment,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runPaymentTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+ await wallet.runUntilDone();
+
+ // Test JSON normalization of contract terms: Does the wallet
+ // agree with the merchant?
+ const order2 = {
+ summary: "Testing “unicode” characters",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order: order2 });
+ await wallet.runUntilDone();
+
+ // Test JSON normalization of contract terms: Does the wallet
+ // agree with the merchant?
+ const order3 = {
+ summary: "Testing\nNewlines\rAnd\tStuff\nHere\b",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order: order3 });
+
+ await wallet.runUntilDone();
+}
+
+runPaymentTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-paywall-flow.ts b/packages/taler-harness/src/integrationtests/test-paywall-flow.ts
new file mode 100644
index 000000000..a9601c625
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-paywall-flow.ts
@@ -0,0 +1,252 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+import {
+ PreparePayResultType,
+ codecForMerchantOrderStatusUnpaid,
+ ConfirmPayResultType,
+ URL,
+} from "@gnu-taler/taler-util";
+import axiosImp from "axios";
+const axios = axiosImp.default;
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runPaywallFlowTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ /**
+ * =========================================================================
+ * Create an order and let the wallet pay under a session ID
+ *
+ * We check along the way that the JSON response to /orders/{order_id}
+ * returns the right thing.
+ * =========================================================================
+ */
+
+ let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ });
+
+ const firstOrderId = orderResp.order_id;
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-one",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUriOne = orderStatus.taler_pay_uri;
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ let publicOrderStatusUrl = new URL(orderStatus.order_status_url);
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ const proposalId = preparePayResp.proposalId;
+
+ console.log("requesting", publicOrderStatusUrl.href);
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+ console.log("response body", publicOrderStatusResp.data);
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ const confirmPayRes = await wallet.client.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId: proposalId,
+ },
+ );
+
+ t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+
+ console.log(publicOrderStatusResp.data);
+
+ if (publicOrderStatusResp.status != 200) {
+ console.log(publicOrderStatusResp.data);
+ throw Error(
+ `expected status 200 (after paying), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ /**
+ * =========================================================================
+ * Now change up the session ID!
+ * =========================================================================
+ */
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-two",
+ });
+
+ // Should be claimed (not paid!) because of a new session ID
+ t.assertTrue(orderStatus.order_status === "claimed");
+
+ // Pay with new taler://pay URI, which should
+ // have the new session ID!
+ // Wallet should now automatically re-play payment.
+ preparePayResp = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: talerPayUriOne,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.AlreadyConfirmed);
+ t.assertTrue(preparePayResp.paid);
+
+ /**
+ * =========================================================================
+ * Now we test re-purchase detection.
+ * =========================================================================
+ */
+
+ orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ // Same fulfillment URL as previously!
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ });
+
+ const secondOrderId = orderResp.order_id;
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: secondOrderId,
+ sessionId: "mysession-three",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ publicOrderStatusUrl = new URL(orderStatus.order_status_url);
+
+ // Here the re-purchase detection should kick in,
+ // and the wallet should re-pay for the old order
+ // under the new session ID (mysession-three).
+ preparePayResp = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.AlreadyConfirmed);
+ t.assertTrue(preparePayResp.paid);
+
+ // The first order should now be paid under "mysession-three",
+ // as the wallet did re-purchase detection
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: firstOrderId,
+ sessionId: "mysession-three",
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ // Check that with a completely new session ID, the status would NOT
+ // be paid.
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: firstOrderId,
+ sessionId: "mysession-four",
+ });
+
+ t.assertTrue(orderStatus.order_status === "claimed");
+
+ // Now check if the public status of the new order is correct.
+
+ console.log("requesting public status", publicOrderStatusUrl);
+
+ // Ask the order status of the claimed-but-unpaid order
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(`expected status 402, but got ${publicOrderStatusResp.status}`);
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ console.log(publicOrderStatusResp.data);
+
+ t.assertTrue(pubUnpaidStatus.already_paid_order_id === firstOrderId);
+}
+
+runPaywallFlowTest.suites = ["merchant", "wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts
new file mode 100644
index 000000000..211f20494
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts
@@ -0,0 +1,101 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { AbsoluteTime, Duration, j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, WalletCli } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runPeerToPeerPullTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange, merchant } = await createSimpleTestkudosEnvironment(
+ t,
+ );
+
+ // Withdraw digital cash into the wallet.
+ const wallet1 = new WalletCli(t, "w1");
+ const wallet2 = new WalletCli(t, "w2");
+ await withdrawViaBank(t, {
+ wallet: wallet2,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wallet1.runUntilDone();
+
+ const purse_expiration = AbsoluteTime.toTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const resp = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPullPayment,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:5",
+ purse_expiration
+ },
+ },
+ );
+
+ const checkResp = await wallet2.client.call(
+ WalletApiOperation.CheckPeerPullPayment,
+ {
+ talerUri: resp.talerUri,
+ },
+ );
+
+ console.log(`checkResp: ${j2s(checkResp)}`);
+
+ const acceptResp = await wallet2.client.call(
+ WalletApiOperation.AcceptPeerPullPayment,
+ {
+ peerPullPaymentIncomingId: checkResp.peerPullPaymentIncomingId,
+ },
+ );
+
+ await wallet1.runUntilDone();
+ await wallet2.runUntilDone();
+
+ const txn1 = await wallet1.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ const txn2 = await wallet2.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log(`txn1: ${j2s(txn1)}`);
+ console.log(`txn2: ${j2s(txn2)}`);
+}
+
+runPeerToPeerPullTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-peer-to-peer-push.ts b/packages/taler-harness/src/integrationtests/test-peer-to-peer-push.ts
new file mode 100644
index 000000000..4aaeca624
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-to-peer-push.ts
@@ -0,0 +1,119 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { AbsoluteTime, Duration, j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, WalletCli } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runPeerToPeerPushTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironment(t);
+
+ const wallet1 = new WalletCli(t, "w1");
+ const wallet2 = new WalletCli(t, "w2");
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, {
+ wallet: wallet1,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wallet1.runUntilDone();
+
+ const purse_expiration = AbsoluteTime.toTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ {
+ const resp = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPushPayment,
+ {
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:5",
+ purse_expiration
+ },
+ },
+ );
+
+ console.log(resp);
+
+ }
+ const resp = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPushPayment,
+ {
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:5",
+ purse_expiration
+ },
+ },
+ );
+
+ console.log(resp);
+
+ const checkResp = await wallet2.client.call(
+ WalletApiOperation.CheckPeerPushPayment,
+ {
+ talerUri: resp.talerUri,
+ },
+ );
+
+ console.log(checkResp);
+
+ const acceptResp = await wallet2.client.call(
+ WalletApiOperation.AcceptPeerPushPayment,
+ {
+ peerPushPaymentIncomingId: checkResp.peerPushPaymentIncomingId,
+ },
+ );
+
+ console.log(acceptResp);
+
+ await wallet1.runUntilDone();
+ await wallet2.runUntilDone();
+
+ const txn1 = await wallet1.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ const txn2 = await wallet2.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log(`txn1: ${j2s(txn1)}`);
+ console.log(`txn2: ${j2s(txn2)}`);
+}
+
+runPeerToPeerPushTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-refund-auto.ts b/packages/taler-harness/src/integrationtests/test-refund-auto.ts
new file mode 100644
index 000000000..4c2a2f94a
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-refund-auto.ts
@@ -0,0 +1,105 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+import { Duration, durationFromSpec } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runRefundAutoTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ // Set up order.
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ auto_refund: {
+ d_us: 3000 * 1000,
+ },
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ durationFromSpec({ minutes: 5 }),
+ ),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const r1 = await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ // FIXME: should be validated, don't cast!
+ proposalId: r1.proposalId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ const ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log(ref);
+
+ // The wallet should now automatically pick up the refund.
+ await wallet.runUntilDone();
+
+ const transactions = await wallet.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(JSON.stringify(transactions, undefined, 2));
+
+ const transactionTypes = transactions.transactions.map((x) => x.type);
+ t.assertDeepEqual(transactionTypes, ["withdrawal", "payment", "refund"]);
+
+ await t.shutdown();
+}
+
+runRefundAutoTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-refund-gone.ts b/packages/taler-harness/src/integrationtests/test-refund-gone.ts
new file mode 100644
index 000000000..b6cefda86
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-refund-gone.ts
@@ -0,0 +1,124 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ applyTimeTravel,
+} from "../harness/helpers.js";
+import {
+ AbsoluteTime,
+ Duration,
+ durationFromSpec,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runRefundGoneTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ // Set up order.
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ pay_deadline: AbsoluteTime.toTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ durationFromSpec({
+ minutes: 10,
+ }),
+ ),
+ ),
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ durationFromSpec({ minutes: 1 }),
+ ),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const r1 = await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ const r2 = await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ proposalId: r1.proposalId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ console.log(orderStatus);
+
+ await applyTimeTravel(durationFromSpec({ hours: 1 }), { exchange, wallet });
+
+ await exchange.runAggregatorOnce();
+
+ const ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log(ref);
+
+ let rr = await wallet.client.call(WalletApiOperation.ApplyRefund, {
+ talerRefundUri: ref.talerRefundUri,
+ });
+
+ console.log("refund response:", rr);
+ t.assertAmountEquals(rr.amountRefundGone, "TESTKUDOS:5");
+
+ await wallet.runUntilDone();
+
+ let r = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ console.log(JSON.stringify(r, undefined, 2));
+
+ const r3 = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ console.log(JSON.stringify(r3, undefined, 2));
+
+ await t.shutdown();
+}
+
+runRefundGoneTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-refund-incremental.ts b/packages/taler-harness/src/integrationtests/test-refund-incremental.ts
new file mode 100644
index 000000000..8d1f6e873
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-refund-incremental.ts
@@ -0,0 +1,202 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ GlobalTestState,
+ delayMs,
+ MerchantPrivateApi,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+import {
+ TransactionType,
+ Amounts,
+ durationFromSpec,
+ Duration,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runRefundIncrementalTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ // Set up order.
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:10",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ durationFromSpec({ minutes: 5 }),
+ ),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const r1 = await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ proposalId: r1.proposalId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ let ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:2.5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log("first refund increase response", ref);
+
+ {
+ let wr = await wallet.client.call(WalletApiOperation.ApplyRefund, {
+ talerRefundUri: ref.talerRefundUri,
+ });
+ console.log(wr);
+ const txs = await wallet.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(
+ "transactions after applying first refund:",
+ JSON.stringify(txs, undefined, 2),
+ );
+ }
+
+ // Wait at least a second, because otherwise the increased
+ // refund will be grouped with the previous one.
+ await delayMs(1200);
+
+ ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "bar",
+ orderId: orderResp.order_id,
+ });
+
+ console.log("second refund increase response", ref);
+
+ // Wait at least a second, because otherwise the increased
+ // refund will be grouped with the previous one.
+ await delayMs(1200);
+
+ ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:10",
+ instance: "default",
+ justification: "bar",
+ orderId: orderResp.order_id,
+ });
+
+ console.log("third refund increase response", ref);
+
+ {
+ let wr = await wallet.client.call(WalletApiOperation.ApplyRefund, {
+ talerRefundUri: ref.talerRefundUri,
+ });
+ console.log(wr);
+ }
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ t.assertAmountEquals(orderStatus.refund_amount, "TESTKUDOS:10");
+
+ console.log(JSON.stringify(orderStatus, undefined, 2));
+
+ await wallet.runUntilDone();
+
+ const bal = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ console.log(JSON.stringify(bal, undefined, 2));
+
+ {
+ const txs = await wallet.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(JSON.stringify(txs, undefined, 2));
+
+ const txTypes = txs.transactions.map((x) => x.type);
+ t.assertDeepEqual(txTypes, [
+ "withdrawal",
+ "payment",
+ "refund",
+ "refund",
+ "refund",
+ ]);
+
+ for (const tx of txs.transactions) {
+ if (tx.type !== TransactionType.Refund) {
+ continue;
+ }
+ t.assertAmountLeq(tx.amountEffective, tx.amountRaw);
+ }
+
+ const raw = Amounts.sum(
+ txs.transactions
+ .filter((x) => x.type === TransactionType.Refund)
+ .map((x) => x.amountRaw),
+ ).amount;
+
+ t.assertAmountEquals("TESTKUDOS:10", raw);
+
+ const effective = Amounts.sum(
+ txs.transactions
+ .filter((x) => x.type === TransactionType.Refund)
+ .map((x) => x.amountEffective),
+ ).amount;
+
+ t.assertAmountEquals("TESTKUDOS:8.59", effective);
+ }
+
+ await t.shutdown();
+}
+
+runRefundIncrementalTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-refund.ts b/packages/taler-harness/src/integrationtests/test-refund.ts
new file mode 100644
index 000000000..b63dad590
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-refund.ts
@@ -0,0 +1,106 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { Duration, durationFromSpec } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runRefundTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ // Set up order.
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ durationFromSpec({ minutes: 5 }),
+ ),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const r1 = await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ proposalId: r1.proposalId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ const ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log(ref);
+
+ let r = await wallet.client.call(WalletApiOperation.ApplyRefund, {
+ talerRefundUri: ref.talerRefundUri,
+ });
+ console.log(r);
+
+ await wallet.runUntilDone();
+
+ {
+ const r2 = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ console.log(JSON.stringify(r2, undefined, 2));
+ }
+ {
+ const r2 = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ console.log(JSON.stringify(r2, undefined, 2));
+ }
+
+ await t.shutdown();
+}
+
+runRefundTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-revocation.ts b/packages/taler-harness/src/integrationtests/test-revocation.ts
new file mode 100644
index 000000000..0fbb4960e
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-revocation.ts
@@ -0,0 +1,215 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import {
+ GlobalTestState,
+ ExchangeService,
+ MerchantService,
+ WalletCli,
+ setupDb,
+ BankService,
+ delayMs,
+ getPayto,
+} from "../harness/harness.js";
+import {
+ withdrawViaBank,
+ makeTestPayment,
+ SimpleTestEnvironment,
+} from "../harness/helpers.js";
+
+async function revokeAllWalletCoins(req: {
+ wallet: WalletCli;
+ exchange: ExchangeService;
+ merchant: MerchantService;
+}): Promise<void> {
+ const { wallet, exchange, merchant } = req;
+ const coinDump = await wallet.client.call(WalletApiOperation.DumpCoins, {});
+ console.log(coinDump);
+ const usedDenomHashes = new Set<string>();
+ for (const coin of coinDump.coins) {
+ usedDenomHashes.add(coin.denom_pub_hash);
+ }
+ for (const x of usedDenomHashes.values()) {
+ await exchange.revokeDenomination(x);
+ }
+ await delayMs(1000);
+ await exchange.keyup();
+ await delayMs(1000);
+ await merchant.stop();
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+}
+
+async function createTestEnvironment(
+ t: GlobalTestState,
+): Promise<SimpleTestEnvironment> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coin_u1: CoinConfig = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ rsaKeySize: 1024,
+ name: `TESTKUDOS_u1`,
+ value: `TESTKUDOS:1`,
+ feeDeposit: `TESTKUDOS:0`,
+ feeRefresh: `TESTKUDOS:0`,
+ feeRefund: `TESTKUDOS:0`,
+ feeWithdraw: `TESTKUDOS:0`,
+ };
+
+ exchange.addCoinConfigList([coin_u1]);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ });
+
+ await merchant.addInstance({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ wallet,
+ bank,
+ exchangeBankAccount,
+ };
+}
+
+/**
+ * Basic time travel test.
+ */
+export async function runRevocationTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } = await createTestEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
+
+ console.log("revoking first time");
+ await revokeAllWalletCoins({ wallet, exchange, merchant });
+
+ // FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565
+ // is implemented.
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ forceUpdate: true,
+ });
+ await wallet.runUntilDone();
+ await wallet.runUntilDone();
+ const bal = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ console.log("wallet balance", bal);
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:10",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+
+ wallet.deleteDatabase();
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
+
+ const coinDump = await wallet.client.call(WalletApiOperation.DumpCoins, {});
+ console.log(coinDump);
+ const coinPubList = coinDump.coins.map((x) => x.coin_pub);
+ await wallet.client.call(WalletApiOperation.ForceRefresh, {
+ coinPubList,
+ });
+ await wallet.runUntilDone();
+
+ console.log("revoking second time");
+ await revokeAllWalletCoins({ wallet, exchange, merchant });
+
+ // FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565
+ // is implemented.
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ forceUpdate: true,
+ });
+ await wallet.runUntilDone();
+ await wallet.runUntilDone();
+ {
+ const bal = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ console.log("wallet balance", bal);
+ }
+
+ await makeTestPayment(t, { wallet, merchant, order });
+}
+
+runRevocationTest.timeoutMs = 120000;
+runRevocationTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts b/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts
new file mode 100644
index 000000000..54b66e0b2
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts
@@ -0,0 +1,216 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ ConfirmPayResultType,
+ Duration,
+ durationFromSpec,
+ PreparePayResultType,
+} from "@gnu-taler/taler-util";
+import {
+ PendingOperationsResponse,
+ WalletApiOperation,
+} from "@gnu-taler/taler-wallet-core";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantPrivateApi,
+ MerchantService,
+ setupDb,
+ WalletCli,
+ getPayto
+} from "../harness/harness.js";
+import { startWithdrawViaBank, withdrawViaBank } from "../harness/helpers.js";
+
+async function applyTimeTravel(
+ timetravelDuration: Duration,
+ s: {
+ exchange?: ExchangeService;
+ merchant?: MerchantService;
+ wallet?: WalletCli;
+ },
+): Promise<void> {
+ if (s.exchange) {
+ await s.exchange.stop();
+ s.exchange.setTimetravel(timetravelDuration);
+ await s.exchange.start();
+ await s.exchange.pingUntilAvailable();
+ }
+
+ if (s.merchant) {
+ await s.merchant.stop();
+ s.merchant.setTimetravel(timetravelDuration);
+ await s.merchant.start();
+ await s.merchant.pingUntilAvailable();
+ }
+
+ if (s.wallet) {
+ console.log("setting wallet time travel to", timetravelDuration);
+ s.wallet.setTimetravel(timetravelDuration);
+ }
+}
+
+/**
+ * Basic time travel test.
+ */
+export async function runTimetravelAutorefreshTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addCoinConfigList(makeNoFeeCoinConfig("TESTKUDOS"));
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ });
+
+ await merchant.addInstance({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
+
+ // Travel into the future, the deposit expiration is two years
+ // into the future.
+ console.log("applying first time travel");
+ await applyTimeTravel(durationFromSpec({ days: 400 }), {
+ wallet,
+ exchange,
+ merchant,
+ });
+
+ await wallet.runUntilDone();
+
+ let p: PendingOperationsResponse;
+ p = await wallet.client.call(WalletApiOperation.GetPendingOperations, {});
+
+ console.log("pending operations after first time travel");
+ console.log(JSON.stringify(p, undefined, 2));
+
+ await startWithdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wallet.runUntilDone();
+
+ // Travel into the future, the deposit expiration is two years
+ // into the future.
+ console.log("applying second time travel");
+ await applyTimeTravel(durationFromSpec({ years: 2, months: 6 }), {
+ wallet,
+ exchange,
+ merchant,
+ });
+
+ // At this point, the original coins should've been refreshed.
+ // It would be too late to refresh them now, as we're past
+ // the two year deposit expiration.
+
+ await wallet.runUntilDone();
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ fulfillment_url: "http://example.com",
+ summary: "foo",
+ amount: "TESTKUDOS:30",
+ },
+ });
+
+ const orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderResp.order_id,
+ instance: "default",
+ },
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const r = await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ console.log(r);
+
+ t.assertTrue(r.status === PreparePayResultType.PaymentPossible);
+
+ const cpr = await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ proposalId: r.proposalId,
+ });
+
+ t.assertTrue(cpr.type === ConfirmPayResultType.Done);
+}
+
+runTimetravelAutorefreshTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-timetravel-withdraw.ts b/packages/taler-harness/src/integrationtests/test-timetravel-withdraw.ts
new file mode 100644
index 000000000..9335af9f5
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-timetravel-withdraw.ts
@@ -0,0 +1,98 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { Duration, TransactionType } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ startWithdrawViaBank,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+
+/**
+ * Basic time travel test.
+ */
+export async function runTimetravelWithdrawTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
+
+ // Travel 400 days into the future,
+ // as the deposit expiration is two years
+ // into the future.
+ const timetravelDuration: Duration = {
+ d_ms: 1000 * 60 * 60 * 24 * 400,
+ };
+
+ await exchange.stop();
+ exchange.setTimetravel(timetravelDuration);
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+ await exchange.keyup();
+
+ await merchant.stop();
+ merchant.setTimetravel(timetravelDuration);
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ console.log("starting withdrawal via bank");
+
+ // This should fail, as the wallet didn't time travel yet.
+ await startWithdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ console.log("starting withdrawal done");
+
+ // Check that transactions are correct for the failed withdrawal
+ {
+ console.log("running until done (should run into maxRetries limit)");
+ await wallet.runUntilDone({ maxRetries: 5 });
+ console.log("wallet done running");
+ const transactions = await wallet.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(transactions);
+ const types = transactions.transactions.map((x) => x.type);
+ t.assertDeepEqual(types, ["withdrawal", "withdrawal"]);
+ const wtrans = transactions.transactions[1];
+ t.assertTrue(wtrans.type === TransactionType.Withdrawal);
+ t.assertTrue(wtrans.pending);
+ }
+
+ // Now we also let the wallet time travel
+
+ wallet.setTimetravel(timetravelDuration);
+
+ // This doesn't work yet, see https://bugs.taler.net/n/6585
+
+ // await wallet.runUntilDone({ maxRetries: 5 });
+}
+
+runTimetravelWithdrawTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-tipping.ts b/packages/taler-harness/src/integrationtests/test-tipping.ts
new file mode 100644
index 000000000..d31e0c06b
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-tipping.ts
@@ -0,0 +1,129 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation, BankApi } from "@gnu-taler/taler-wallet-core";
+import {
+ GlobalTestState,
+ MerchantPrivateApi,
+ getWireMethodForTest,
+} from "../harness/harness.js";
+import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runTippingTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, merchant, exchangeBankAccount } =
+ await createSimpleTestkudosEnvironment(t);
+
+ const mbu = await BankApi.createRandomBankUser(bank);
+
+ const tipReserveResp = await MerchantPrivateApi.createTippingReserve(
+ merchant,
+ "default",
+ {
+ exchange_url: exchange.baseUrl,
+ initial_balance: "TESTKUDOS:10",
+ wire_method: getWireMethodForTest(),
+ },
+ );
+
+ console.log("tipReserveResp:", tipReserveResp);
+
+ t.assertDeepEqual(
+ tipReserveResp.payto_uri,
+ exchangeBankAccount.accountPaytoUri,
+ );
+
+ await BankApi.adminAddIncoming(bank, {
+ amount: "TESTKUDOS:10",
+ debitAccountPayto: mbu.accountPaytoUri,
+ exchangeBankAccount,
+ reservePub: tipReserveResp.reserve_pub,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await merchant.stop();
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ const r = await MerchantPrivateApi.queryTippingReserves(merchant, "default");
+ console.log("tipping reserves:", JSON.stringify(r, undefined, 2));
+
+ t.assertTrue(r.reserves.length === 1);
+ t.assertDeepEqual(
+ r.reserves[0].exchange_initial_amount,
+ r.reserves[0].merchant_initial_amount,
+ );
+
+ const tip = await MerchantPrivateApi.giveTip(merchant, "default", {
+ amount: "TESTKUDOS:5",
+ justification: "why not?",
+ next_url: "https://example.com/after-tip",
+ });
+
+ console.log("created tip", tip);
+
+ const doTip = async (): Promise<void> => {
+ const ptr = await wallet.client.call(WalletApiOperation.PrepareTip, {
+ talerTipUri: tip.taler_tip_uri,
+ });
+
+ console.log(ptr);
+
+ t.assertAmountEquals(ptr.tipAmountRaw, "TESTKUDOS:5");
+ t.assertAmountEquals(ptr.tipAmountEffective, "TESTKUDOS:4.85");
+
+ await wallet.client.call(WalletApiOperation.AcceptTip, {
+ walletTipId: ptr.walletTipId,
+ });
+
+ await wallet.runUntilDone();
+
+ const bal = await wallet.client.call(WalletApiOperation.GetBalances, {});
+
+ console.log(bal);
+
+ t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:4.85");
+
+ const txns = await wallet.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log("Transactions:", JSON.stringify(txns, undefined, 2));
+
+ t.assertDeepEqual(txns.transactions[0].type, "tip");
+ t.assertDeepEqual(txns.transactions[0].pending, false);
+ t.assertAmountEquals(
+ txns.transactions[0].amountEffective,
+ "TESTKUDOS:4.85",
+ );
+ t.assertAmountEquals(txns.transactions[0].amountRaw, "TESTKUDOS:5.0");
+ };
+
+ // Check twice so make sure tip handling is idempotent
+ await doTip();
+ await doTip();
+}
+
+runTippingTest.suites = ["wallet", "wallet-tipping"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-backup-basic.ts b/packages/taler-harness/src/integrationtests/test-wallet-backup-basic.ts
new file mode 100644
index 000000000..fc2f3335d
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-backup-basic.ts
@@ -0,0 +1,168 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, WalletCli } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+import { SyncService } from "../harness/sync.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWalletBackupBasicTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { commonDb, merchant, wallet, bank, exchange } =
+ await createSimpleTestkudosEnvironment(t);
+
+ const sync = await SyncService.create(t, {
+ currency: "TESTKUDOS",
+ annualFee: "TESTKUDOS:0.5",
+ database: commonDb.connStr,
+ fulfillmentUrl: "taler://fulfillment-success",
+ httpPort: 8089,
+ name: "sync1",
+ paymentBackendUrl: merchant.makeInstanceBaseUrl(),
+ uploadLimitMb: 10,
+ });
+
+ await sync.start();
+ await sync.pingUntilAvailable();
+
+ await wallet.client.call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: sync.baseUrl,
+ activate: false,
+ name: sync.baseUrl,
+ });
+
+ {
+ const bi = await wallet.client.call(WalletApiOperation.GetBackupInfo, {});
+ t.assertDeepEqual(bi.providers[0].active, false);
+ }
+
+ await wallet.client.call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: sync.baseUrl,
+ activate: true,
+ name: sync.baseUrl,
+ });
+
+ {
+ const bi = await wallet.client.call(WalletApiOperation.GetBackupInfo, {});
+ t.assertDeepEqual(bi.providers[0].active, true);
+ }
+
+ await wallet.client.call(WalletApiOperation.RunBackupCycle, {});
+
+ {
+ const bi = await wallet.client.call(WalletApiOperation.GetBackupInfo, {});
+ console.log(bi);
+ t.assertDeepEqual(
+ bi.providers[0].paymentStatus.type,
+ "insufficient-balance",
+ );
+ }
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:10" });
+
+ await wallet.runUntilDone();
+
+ await wallet.client.call(WalletApiOperation.RunBackupCycle, {});
+
+ {
+ const bi = await wallet.client.call(WalletApiOperation.GetBackupInfo, {});
+ console.log(bi);
+ }
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:5" });
+
+ await wallet.client.call(WalletApiOperation.RunBackupCycle, {});
+
+ {
+ const bi = await wallet.client.call(WalletApiOperation.GetBackupInfo, {});
+ console.log(bi);
+ }
+
+ const backupRecovery = await wallet.client.call(
+ WalletApiOperation.ExportBackupRecovery,
+ {},
+ );
+
+ const txs = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ console.log(`backed up transactions ${j2s(txs)}`);
+
+ const wallet2 = new WalletCli(t, "wallet2");
+
+ // Check that the second wallet is a fresh wallet.
+ {
+ const bal = await wallet2.client.call(WalletApiOperation.GetBalances, {});
+ t.assertTrue(bal.balances.length === 0);
+ }
+
+ await wallet2.client.call(WalletApiOperation.ImportBackupRecovery, {
+ recovery: backupRecovery,
+ });
+
+ await wallet2.client.call(WalletApiOperation.RunBackupCycle, {});
+
+ // Check that now the old balance is available!
+ {
+ const bal = await wallet2.client.call(WalletApiOperation.GetBalances, {});
+ t.assertTrue(bal.balances.length === 1);
+ console.log(bal);
+ }
+
+ // Now do some basic checks that the restored wallet is still functional
+ {
+ const txs = await wallet2.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(`restored transactions ${j2s(txs)}`);
+ const bal1 = await wallet2.client.call(WalletApiOperation.GetBalances, {});
+
+ t.assertAmountEquals(bal1.balances[0].available, "TESTKUDOS:14.1");
+
+ await withdrawViaBank(t, {
+ wallet: wallet2,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:10",
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await wallet2.runUntilDone();
+
+ const txs2 = await wallet2.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(`tx after withdraw after restore ${j2s(txs2)}`);
+
+ const bal2 = await wallet2.client.call(WalletApiOperation.GetBalances, {});
+
+ t.assertAmountEquals(bal2.balances[0].available, "TESTKUDOS:23.82");
+ }
+}
+
+runWalletBackupBasicTest.suites = ["wallet", "wallet-backup"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-backup-doublespend.ts b/packages/taler-harness/src/integrationtests/test-wallet-backup-doublespend.ts
new file mode 100644
index 000000000..8b52260e9
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-backup-doublespend.ts
@@ -0,0 +1,174 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { PreparePayResultType } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ GlobalTestState,
+ WalletCli,
+ MerchantPrivateApi,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironment,
+ makeTestPayment,
+ withdrawViaBank,
+} from "../harness/helpers.js";
+import { SyncService } from "../harness/sync.js";
+
+export async function runWalletBackupDoublespendTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { commonDb, merchant, wallet, bank, exchange } =
+ await createSimpleTestkudosEnvironment(t);
+
+ const sync = await SyncService.create(t, {
+ currency: "TESTKUDOS",
+ annualFee: "TESTKUDOS:0.5",
+ database: commonDb.connStr,
+ fulfillmentUrl: "taler://fulfillment-success",
+ httpPort: 8089,
+ name: "sync1",
+ paymentBackendUrl: merchant.makeInstanceBaseUrl(),
+ uploadLimitMb: 10,
+ });
+
+ await sync.start();
+ await sync.pingUntilAvailable();
+
+ await wallet.client.call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: sync.baseUrl,
+ activate: true,
+ name: sync.baseUrl,
+ });
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:10" });
+
+ await wallet.runUntilDone();
+
+ await wallet.client.call(WalletApiOperation.RunBackupCycle, {});
+ await wallet.runUntilDone();
+ await wallet.client.call(WalletApiOperation.RunBackupCycle, {});
+
+ const backupRecovery = await wallet.client.call(
+ WalletApiOperation.ExportBackupRecovery,
+ {},
+ );
+
+ const wallet2 = new WalletCli(t, "wallet2");
+
+ await wallet2.client.call(WalletApiOperation.ImportBackupRecovery, {
+ recovery: backupRecovery,
+ });
+
+ await wallet2.client.call(WalletApiOperation.RunBackupCycle, {});
+
+ console.log(
+ "wallet1 balance before spend:",
+ await wallet.client.call(WalletApiOperation.GetBalances, {}),
+ );
+
+ await makeTestPayment(t, {
+ merchant,
+ wallet,
+ order: {
+ summary: "foo",
+ amount: "TESTKUDOS:7",
+ },
+ });
+
+ await wallet.runUntilDone();
+
+ console.log(
+ "wallet1 balance after spend:",
+ await wallet.client.call(WalletApiOperation.GetBalances, {}),
+ );
+
+ {
+ console.log(
+ "wallet2 balance:",
+ await wallet2.client.call(WalletApiOperation.GetBalances, {}),
+ );
+ }
+
+ // Now we double-spend with the second wallet
+
+ {
+ const instance = "default";
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, instance, {
+ order: {
+ amount: "TESTKUDOS:8",
+ summary: "bla",
+ fulfillment_url: "taler://fulfillment-success",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderResp.order_id,
+ },
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ {
+ console.log(
+ "wallet2 balance before preparePay:",
+ await wallet2.client.call(WalletApiOperation.GetBalances, {}),
+ );
+ }
+
+ const preparePayResult = await wallet2.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertDeepEqual(
+ preparePayResult.status,
+ PreparePayResultType.PaymentPossible,
+ );
+
+ const res = await wallet2.client.call(WalletApiOperation.ConfirmPay, {
+ proposalId: preparePayResult.proposalId,
+ });
+
+ console.log(res);
+
+ // FIXME: wait for a notification that indicates insufficient funds!
+
+ await withdrawViaBank(t, {
+ wallet: wallet2,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:50",
+ });
+
+ const bal = await wallet2.client.call(WalletApiOperation.GetBalances, {});
+ console.log("bal", bal);
+
+ await wallet2.runUntilDone();
+ }
+}
+
+runWalletBackupDoublespendTest.suites = ["wallet", "wallet-backup"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-balance.ts b/packages/taler-harness/src/integrationtests/test-wallet-balance.ts
new file mode 100644
index 000000000..f5226c6c0
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-balance.ts
@@ -0,0 +1,144 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { Duration, PreparePayResultType } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ FakebankService,
+ getRandomIban,
+ GlobalTestState,
+ MerchantPrivateApi,
+ MerchantService,
+ setupDb,
+ WalletCli,
+} from "../harness/harness.js";
+import { withdrawViaBank } from "../harness/helpers.js";
+
+/**
+ * Test for wallet balance error messages / different types of insufficient balance.
+ *
+ * Related bugs:
+ * https://bugs.taler.net/n/7299
+ */
+export async function runWalletBalanceTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await FakebankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ // Fakebank uses x-taler-bank, but merchant is configured to only accept sepa!
+ const label = "mymerchant";
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [
+ `payto://iban/SANDBOXX/${getRandomIban(label)}?receiver-name=${label}`,
+ ],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order,
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await wallet.client.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertDeepEqual(
+ preparePayResult.status,
+ PreparePayResultType.InsufficientBalance,
+ );
+
+ await wallet.runUntilDone();
+}
+
+runWalletBalanceTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-cryptoworker.ts b/packages/taler-harness/src/integrationtests/test-wallet-cryptoworker.ts
new file mode 100644
index 000000000..a9f1c4d80
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-cryptoworker.ts
@@ -0,0 +1,55 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { j2s } from "@gnu-taler/taler-util";
+import {
+ checkReserve,
+ CryptoDispatcher,
+ depositCoin,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ NodeHttpLib,
+ refreshCoin,
+ SynchronousCryptoWorkerFactoryNode,
+ TalerError,
+ topupReserveWithDemobank,
+ WalletApiOperation,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, WalletCli } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
+
+/**
+ * Run test for the different crypto workers.
+ */
+export async function runWalletCryptoWorkerTest(t: GlobalTestState) {
+ const wallet1 = new WalletCli(t, "w1", {
+ cryptoWorkerType: "sync",
+ });
+
+ await wallet1.client.call(WalletApiOperation.TestCrypto, {});
+
+ const wallet2 = new WalletCli(t, "w2", {
+ cryptoWorkerType: "node-worker-thread",
+ });
+
+ await wallet2.client.call(WalletApiOperation.TestCrypto, {});
+}
+
+runWalletCryptoWorkerTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-dbless.ts b/packages/taler-harness/src/integrationtests/test-wallet-dbless.ts
new file mode 100644
index 000000000..269a8b240
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-dbless.ts
@@ -0,0 +1,112 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { j2s } from "@gnu-taler/taler-util";
+import {
+ checkReserve,
+ CryptoDispatcher,
+ depositCoin,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ NodeHttpLib,
+ refreshCoin,
+ SynchronousCryptoWorkerFactoryNode,
+ TalerError,
+ topupReserveWithDemobank,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runWalletDblessTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironment(t);
+
+ const http = new NodeHttpLib();
+ const cryptiDisp = new CryptoDispatcher(new SynchronousCryptoWorkerFactoryNode());
+ const cryptoApi = cryptiDisp.cryptoApi;
+
+ try {
+ // Withdraw digital cash into the wallet.
+
+ const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http);
+
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+
+ await topupReserveWithDemobank(
+ http,
+ reserveKeyPair.pub,
+ bank.baseUrl,
+ bank.bankAccessApiBaseUrl,
+ exchangeInfo,
+ "TESTKUDOS:10",
+ );
+
+ await exchange.runWirewatchOnce();
+
+ await checkReserve(http, exchange.baseUrl, reserveKeyPair.pub);
+
+ const d1 = findDenomOrThrow(exchangeInfo, "TESTKUDOS:8");
+
+ const coin = await withdrawCoin({
+ http,
+ cryptoApi,
+ reserveKeyPair: {
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ },
+ denom: d1,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ await depositCoin({
+ amount: "TESTKUDOS:4",
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+
+ const refreshDenoms = [
+ findDenomOrThrow(exchangeInfo, "TESTKUDOS:1"),
+ findDenomOrThrow(exchangeInfo, "TESTKUDOS:1"),
+ ];
+
+ await refreshCoin({
+ oldCoin: coin,
+ cryptoApi,
+ http,
+ newDenoms: refreshDenoms,
+ });
+ } catch (e) {
+ if (e instanceof TalerError) {
+ console.log(e);
+ console.log(j2s(e.errorDetail));
+ } else {
+ console.log(e);
+ }
+ throw e;
+ }
+}
+
+runWalletDblessTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallettesting.ts b/packages/taler-harness/src/integrationtests/test-wallettesting.ts
new file mode 100644
index 000000000..03c446db3
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallettesting.ts
@@ -0,0 +1,233 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Integration test for the wallet testing functionality used by the exchange
+ * test cases.
+ */
+
+/**
+ * Imports.
+ */
+import { Amounts, CoinStatus } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ setupDb,
+ WalletCli,
+ getPayto,
+} from "../harness/harness.js";
+import { SimpleTestEnvironment } from "../harness/helpers.js";
+
+const merchantAuthToken = "secret-token:sandbox";
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ */
+export async function createMyEnvironment(
+ t: GlobalTestState,
+ coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
+): Promise<SimpleTestEnvironment> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstance({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [getPayto("merchant-default")],
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ wallet,
+ bank,
+ exchangeBankAccount,
+ };
+}
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWallettestingTest(t: GlobalTestState) {
+ const { wallet, bank, exchange, merchant } = await createMyEnvironment(t);
+
+ await wallet.client.call(WalletApiOperation.RunIntegrationTest, {
+ amountToSpend: "TESTKUDOS:5",
+ amountToWithdraw: "TESTKUDOS:10",
+ bankBaseUrl: bank.baseUrl,
+ bankAccessApiBaseUrl: bank.bankAccessApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ merchantAuthToken: merchantAuthToken,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ });
+
+ let txns = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ console.log(JSON.stringify(txns, undefined, 2));
+ let txTypes = txns.transactions.map((x) => x.type);
+
+ t.assertDeepEqual(txTypes, [
+ "withdrawal",
+ "payment",
+ "withdrawal",
+ "payment",
+ "refund",
+ "payment",
+ ]);
+
+ wallet.deleteDatabase();
+
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ amount: "TESTKUDOS:10",
+ bankBaseUrl: bank.baseUrl,
+ bankAccessApiBaseUrl: bank.bankAccessApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ await wallet.runUntilDone();
+
+ await wallet.client.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:5",
+ merchantAuthToken: merchantAuthToken,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ summary: "foo",
+ });
+
+ await wallet.runUntilDone();
+
+ txns = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ console.log(JSON.stringify(txns, undefined, 2));
+ txTypes = txns.transactions.map((x) => x.type);
+
+ t.assertDeepEqual(txTypes, ["withdrawal", "payment"]);
+
+ wallet.deleteDatabase();
+
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ amount: "TESTKUDOS:10",
+ bankBaseUrl: bank.baseUrl,
+ bankAccessApiBaseUrl: bank.bankAccessApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ await wallet.runUntilDone();
+
+ const coinDump = await wallet.client.call(WalletApiOperation.DumpCoins, {});
+
+ console.log("coin dump:", JSON.stringify(coinDump, undefined, 2));
+
+ let susp: string | undefined;
+ {
+ for (const c of coinDump.coins) {
+ if (
+ c.coin_status === CoinStatus.Fresh &&
+ 0 === Amounts.cmp(c.denom_value, "TESTKUDOS:8")
+ ) {
+ susp = c.coin_pub;
+ }
+ }
+ }
+
+ t.assertTrue(susp !== undefined);
+
+ console.log("suspending coin");
+
+ await wallet.client.call(WalletApiOperation.SetCoinSuspended, {
+ coinPub: susp,
+ suspended: true,
+ });
+
+ // This should fail, as we've suspended a coin that we need
+ // to pay.
+ await t.assertThrowsAsync(async () => {
+ await wallet.client.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:5",
+ merchantAuthToken: merchantAuthToken,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ summary: "foo",
+ });
+ });
+
+ console.log("unsuspending coin");
+
+ await wallet.client.call(WalletApiOperation.SetCoinSuspended, {
+ coinPub: susp,
+ suspended: false,
+ });
+
+ await wallet.client.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:5",
+ merchantAuthToken: merchantAuthToken,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ summary: "foo",
+ });
+
+ await t.shutdown();
+}
+
+runWallettestingTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-abort-bank.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-abort-bank.ts
new file mode 100644
index 000000000..bf2dc0133
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-abort-bank.ts
@@ -0,0 +1,84 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { TalerErrorCode } from "@gnu-taler/taler-util";
+import {
+ WalletApiOperation,
+ BankApi,
+ BankAccessApi,
+} from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWithdrawalAbortBankTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange } = await createSimpleTestkudosEnvironment(t);
+
+ // Create a withdrawal operation
+
+ const user = await BankApi.createRandomBankUser(bank);
+ const wop = await BankAccessApi.createWithdrawalOperation(
+ bank,
+ user,
+ "TESTKUDOS:10",
+ );
+
+ // Hand it to the wallet
+
+ await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+
+ await wallet.runPending();
+
+ // Abort it
+
+ await BankApi.abortWithdrawalOperation(bank, user, wop);
+ //await BankApi.confirmWithdrawalOperation(bank, user, wop);
+
+ // Withdraw
+
+ // Difference:
+ // -> with euFin, the wallet selects
+ // -> with PyBank, the wallet stops _before_
+ //
+ // WHY ?!
+ //
+ const e = await t.assertThrowsTalerErrorAsync(async () => {
+ await wallet.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+ });
+ t.assertDeepEqual(
+ e.errorDetail.code,
+ TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
+ );
+
+ await t.shutdown();
+}
+
+runWithdrawalAbortBankTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts
new file mode 100644
index 000000000..dc7298e5d
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts
@@ -0,0 +1,91 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
+import {
+ WalletApiOperation,
+ BankApi,
+ BankAccessApi,
+} from "@gnu-taler/taler-wallet-core";
+import { j2s } from "@gnu-taler/taler-util";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange } = await createSimpleTestkudosEnvironment(t);
+
+ // Create a withdrawal operation
+
+ const user = await BankApi.createRandomBankUser(bank);
+ const wop = await BankAccessApi.createWithdrawalOperation(
+ bank,
+ user,
+ "TESTKUDOS:10",
+ );
+
+ // Hand it to the wallet
+
+ const r1 = await wallet.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ await wallet.runPending();
+
+ // Withdraw
+
+ const r2 = await wallet.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+ // Do it twice to check idempotency
+ const r3 = await wallet.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+ await wallet.runPending();
+
+ // Confirm it
+
+ await BankApi.confirmWithdrawalOperation(bank, user, wop);
+
+ await wallet.runUntilDone();
+
+ // Check balance
+
+ const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
+
+ const txn = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ console.log(`transactions: ${j2s(txn)}`);
+}
+
+runWithdrawalBankIntegratedTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts
new file mode 100644
index 000000000..ec6e54e6c
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts
@@ -0,0 +1,97 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ GlobalTestState,
+ WalletCli,
+ setupDb,
+ ExchangeService,
+ FakebankService,
+} from "../harness/harness.js";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import { URL } from "@gnu-taler/taler-util";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWithdrawalFakebankTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await FakebankService.create(t, {
+ currency: "TESTKUDOS",
+ httpPort: 8082,
+ allowRegistrations: true,
+ // Not used by fakebank
+ database: db.connStr,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ exchange.addBankAccount("1", {
+ accountName: "exchange",
+ accountPassword: "x",
+ wireGatewayApiBaseUrl: new URL("/exchange/", bank.baseUrl).href,
+ accountPaytoUri: "payto://x-taler-bank/localhost/exchange",
+ });
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ await wallet.client.call(WalletApiOperation.WithdrawFakebank, {
+ exchange: exchange.baseUrl,
+ amount: "TESTKUDOS:10",
+ bank: bank.baseUrl,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await wallet.runUntilDone();
+
+ // Check balance
+
+ const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
+
+ await t.shutdown();
+}
+
+runWithdrawalFakebankTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-high.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-high.ts
new file mode 100644
index 000000000..deb0e6dde
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-high.ts
@@ -0,0 +1,99 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ GlobalTestState,
+ WalletCli,
+ setupDb,
+ ExchangeService,
+ FakebankService,
+} from "../harness/harness.js";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import { URL } from "@gnu-taler/taler-util";
+
+/**
+ * Withdraw a high amount. Mostly intended
+ * as a perf test.
+ */
+export async function runWithdrawalHighTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await FakebankService.create(t, {
+ currency: "TESTKUDOS",
+ httpPort: 8082,
+ allowRegistrations: true,
+ // Not used by fakebank
+ database: db.connStr,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ exchange.addBankAccount("1", {
+ accountName: "exchange",
+ accountPassword: "x",
+ wireGatewayApiBaseUrl: new URL("/exchange/", bank.baseUrl).href,
+ accountPaytoUri: "payto://x-taler-bank/localhost/exchange",
+ });
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ await wallet.client.call(WalletApiOperation.WithdrawFakebank, {
+ exchange: exchange.baseUrl,
+ amount: "TESTKUDOS:5000",
+ bank: bank.baseUrl,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await wallet.runUntilDone();
+
+ // Check balance
+
+ const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ console.log(balResp);
+
+ await t.shutdown();
+}
+
+runWithdrawalHighTest.suites = ["wallet-perf"];
+runWithdrawalHighTest.excludeByDefault = true;
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts
new file mode 100644
index 000000000..b691ae508
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts
@@ -0,0 +1,84 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
+import { WalletApiOperation, BankApi } from "@gnu-taler/taler-wallet-core";
+import {
+ AbsoluteTime,
+ Duration,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runTestWithdrawalManualTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange, exchangeBankAccount } =
+ await createSimpleTestkudosEnvironment(t);
+
+ // Create a withdrawal operation
+
+ const user = await BankApi.createRandomBankUser(bank);
+
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const tStart = AbsoluteTime.now();
+
+ // We expect this to return immediately.
+ const wres = await wallet.client.call(
+ WalletApiOperation.AcceptManualWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10",
+ },
+ );
+
+ // Check that the request did not go into long-polling.
+ const duration = AbsoluteTime.difference(tStart, AbsoluteTime.now());
+ if (duration.d_ms > 5 * 1000) {
+ throw Error("withdrawal took too long (longpolling issue)");
+ }
+
+ const reservePub: string = wres.reservePub;
+
+ await BankApi.adminAddIncoming(bank, {
+ exchangeBankAccount,
+ amount: "TESTKUDOS:10",
+ debitAccountPayto: user.accountPaytoUri,
+ reservePub: reservePub,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await wallet.runUntilDone();
+
+ // Check balance
+
+ const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
+
+ await t.shutdown();
+}
+
+runTestWithdrawalManualTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
new file mode 100644
index 000000000..4b1c28bde
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -0,0 +1,496 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { CancellationToken, minimatch } from "@gnu-taler/taler-util";
+import * as child_process from "child_process";
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import url from "url";
+import {
+ GlobalTestState,
+ runTestWithState,
+ shouldLingerInTest,
+ TestRunResult,
+} from "../harness/harness.js";
+import { runAgeRestrictionsMerchantTest } from "./test-age-restrictions-merchant.js";
+import { runBankApiTest } from "./test-bank-api.js";
+import { runClaimLoopTest } from "./test-claim-loop.js";
+import { runClauseSchnorrTest } from "./test-clause-schnorr.js";
+import { runDenomUnofferedTest } from "./test-denom-unoffered.js";
+import { runDepositTest } from "./test-deposit.js";
+import { runExchangeManagementTest } from "./test-exchange-management.js";
+import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js";
+import { runFeeRegressionTest } from "./test-fee-regression.js";
+import { runForcedSelectionTest } from "./test-forced-selection.js";
+import { runLibeufinApiBankaccountTest } from "./test-libeufin-api-bankaccount.js";
+import { runLibeufinApiBankconnectionTest } from "./test-libeufin-api-bankconnection.js";
+import { runLibeufinApiFacadeTest } from "./test-libeufin-api-facade.js";
+import { runLibeufinApiFacadeBadRequestTest } from "./test-libeufin-api-facade-bad-request.js";
+import { runLibeufinApiPermissionsTest } from "./test-libeufin-api-permissions.js";
+import { runLibeufinApiSandboxCamtTest } from "./test-libeufin-api-sandbox-camt.js";
+import { runLibeufinApiSandboxTransactionsTest } from "./test-libeufin-api-sandbox-transactions.js";
+import { runLibeufinApiSchedulingTest } from "./test-libeufin-api-scheduling.js";
+import { runLibeufinApiUsersTest } from "./test-libeufin-api-users.js";
+import { runLibeufinBadGatewayTest } from "./test-libeufin-bad-gateway.js";
+import { runLibeufinBasicTest } from "./test-libeufin-basic.js";
+import { runLibeufinC5xTest } from "./test-libeufin-c5x.js";
+import { runLibeufinAnastasisFacadeTest } from "./test-libeufin-facade-anastasis.js";
+import { runLibeufinKeyrotationTest } from "./test-libeufin-keyrotation.js";
+import { runLibeufinNexusBalanceTest } from "./test-libeufin-nexus-balance.js";
+import { runLibeufinRefundTest } from "./test-libeufin-refund.js";
+import { runLibeufinRefundMultipleUsersTest } from "./test-libeufin-refund-multiple-users.js";
+import { runLibeufinSandboxWireTransferCliTest } from "./test-libeufin-sandbox-wire-transfer-cli.js";
+import { runLibeufinTutorialTest } from "./test-libeufin-tutorial.js";
+import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion.js";
+import { runMerchantInstancesTest } from "./test-merchant-instances.js";
+import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete.js";
+import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls.js";
+import { runMerchantLongpollingTest } from "./test-merchant-longpolling.js";
+import { runMerchantRefundApiTest } from "./test-merchant-refund-api.js";
+import { runMerchantSpecPublicOrdersTest } from "./test-merchant-spec-public-orders.js";
+import { runPayPaidTest } from "./test-pay-paid.js";
+import { runPaymentTest } from "./test-payment.js";
+import { runPaymentClaimTest } from "./test-payment-claim.js";
+import { runPaymentFaultTest } from "./test-payment-fault.js";
+import { runPaymentForgettableTest } from "./test-payment-forgettable.js";
+import { runPaymentIdempotencyTest } from "./test-payment-idempotency.js";
+import { runPaymentMultipleTest } from "./test-payment-multiple.js";
+import { runPaymentDemoTest } from "./test-payment-on-demo.js";
+import { runPaymentTransientTest } from "./test-payment-transient.js";
+import { runPaymentZeroTest } from "./test-payment-zero.js";
+import { runPaywallFlowTest } from "./test-paywall-flow.js";
+import { runPeerToPeerPullTest } from "./test-peer-to-peer-pull.js";
+import { runPeerToPeerPushTest } from "./test-peer-to-peer-push.js";
+import { runRefundTest } from "./test-refund.js";
+import { runRefundAutoTest } from "./test-refund-auto.js";
+import { runRefundGoneTest } from "./test-refund-gone.js";
+import { runRefundIncrementalTest } from "./test-refund-incremental.js";
+import { runRevocationTest } from "./test-revocation.js";
+import { runTimetravelAutorefreshTest } from "./test-timetravel-autorefresh.js";
+import { runTimetravelWithdrawTest } from "./test-timetravel-withdraw.js";
+import { runTippingTest } from "./test-tipping.js";
+import { runWalletBackupBasicTest } from "./test-wallet-backup-basic.js";
+import { runWalletBackupDoublespendTest } from "./test-wallet-backup-doublespend.js";
+import { runWalletDblessTest } from "./test-wallet-dbless.js";
+import { runWallettestingTest } from "./test-wallettesting.js";
+import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank.js";
+import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated.js";
+import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js";
+import { runTestWithdrawalManualTest } from "./test-withdrawal-manual.js";
+import { runAgeRestrictionsPeerTest } from "./test-age-restrictions-peer.js";
+import { runWalletBalanceTest } from "./test-wallet-balance.js";
+import { runAgeRestrictionsMixedMerchantTest } from "./test-age-restrictions-mixed-merchant.js";
+import { runWalletCryptoWorkerTest } from "./test-wallet-cryptoworker.js";
+import { runWithdrawalHighTest } from "./test-withdrawal-high.js";
+
+/**
+ * Test runner.
+ */
+
+/**
+ * Spec for one test.
+ */
+interface TestMainFunction {
+ (t: GlobalTestState): Promise<void>;
+ timeoutMs?: number;
+ excludeByDefault?: boolean;
+ suites?: string[];
+}
+
+const allTests: TestMainFunction[] = [
+ runAgeRestrictionsMerchantTest,
+ runAgeRestrictionsPeerTest,
+ runAgeRestrictionsMixedMerchantTest,
+ runBankApiTest,
+ runClaimLoopTest,
+ runClauseSchnorrTest,
+ runWalletCryptoWorkerTest,
+ runDepositTest,
+ runDenomUnofferedTest,
+ runExchangeManagementTest,
+ runExchangeTimetravelTest,
+ runFeeRegressionTest,
+ runForcedSelectionTest,
+ runLibeufinBasicTest,
+ runLibeufinKeyrotationTest,
+ runLibeufinTutorialTest,
+ runLibeufinRefundTest,
+ runLibeufinC5xTest,
+ runLibeufinNexusBalanceTest,
+ runLibeufinBadGatewayTest,
+ runLibeufinRefundMultipleUsersTest,
+ runLibeufinApiPermissionsTest,
+ runLibeufinApiFacadeTest,
+ runLibeufinApiFacadeBadRequestTest,
+ runLibeufinAnastasisFacadeTest,
+ runLibeufinApiSchedulingTest,
+ runLibeufinApiUsersTest,
+ runLibeufinApiBankaccountTest,
+ runLibeufinApiBankconnectionTest,
+ runLibeufinApiSandboxTransactionsTest,
+ runLibeufinApiSandboxCamtTest,
+ runLibeufinSandboxWireTransferCliTest,
+ runMerchantExchangeConfusionTest,
+ runMerchantInstancesTest,
+ runMerchantInstancesDeleteTest,
+ runMerchantInstancesUrlsTest,
+ runMerchantLongpollingTest,
+ runMerchantSpecPublicOrdersTest,
+ runMerchantRefundApiTest,
+ runPaymentClaimTest,
+ runPaymentFaultTest,
+ runPaymentForgettableTest,
+ runPaymentIdempotencyTest,
+ runPaymentMultipleTest,
+ runPaymentTest,
+ runPaymentDemoTest,
+ runPaymentTransientTest,
+ runPaymentZeroTest,
+ runPayPaidTest,
+ runPaywallFlowTest,
+ runPeerToPeerPushTest,
+ runPeerToPeerPullTest,
+ runRefundAutoTest,
+ runRefundGoneTest,
+ runRefundIncrementalTest,
+ runRefundTest,
+ runRevocationTest,
+ runTestWithdrawalManualTest,
+ runWithdrawalFakebankTest,
+ runTimetravelAutorefreshTest,
+ runTimetravelWithdrawTest,
+ runTippingTest,
+ runWalletBackupBasicTest,
+ runWalletBackupDoublespendTest,
+ runWalletBalanceTest,
+ runWithdrawalHighTest,
+ runWallettestingTest,
+ runWalletDblessTest,
+ runWithdrawalAbortBankTest,
+ runWithdrawalBankIntegratedTest,
+];
+
+export interface TestRunSpec {
+ includePattern?: string;
+ suiteSpec?: string;
+ dryRun?: boolean;
+ verbosity: number;
+}
+
+export interface TestInfo {
+ name: string;
+ suites: string[];
+ excludeByDefault: boolean;
+}
+
+function updateCurrentSymlink(testDir: string): void {
+ const currLink = path.join(
+ os.tmpdir(),
+ `taler-integrationtests-${os.userInfo().username}-current`,
+ );
+ try {
+ fs.unlinkSync(currLink);
+ } catch (e) {
+ // Ignore
+ }
+ try {
+ fs.symlinkSync(testDir, currLink);
+ } catch (e) {
+ console.log(e);
+ // Ignore
+ }
+}
+
+export function getTestName(tf: TestMainFunction): string {
+ const res = tf.name.match(/run([a-zA-Z0-9]*)Test/);
+ if (!res) {
+ throw Error("invalid test name, must be 'run${NAME}Test'");
+ }
+ return res[1]
+ .replace(/[a-z0-9][A-Z]/g, (x) => {
+ return x[0] + "-" + x[1];
+ })
+ .toLowerCase();
+}
+
+interface RunTestChildInstruction {
+ testName: string;
+ testRootDir: string;
+}
+
+export async function runTests(spec: TestRunSpec) {
+ const testRootDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "taler-integrationtests-"),
+ );
+ updateCurrentSymlink(testRootDir);
+ console.log(`testsuite root directory: ${testRootDir}`);
+
+ const testResults: TestRunResult[] = [];
+
+ let currentChild: child_process.ChildProcess | undefined;
+
+ const handleSignal = (s: NodeJS.Signals) => {
+ console.log(`received signal ${s} in test parent`);
+ if (currentChild) {
+ currentChild.kill("SIGTERM");
+ }
+ reportAndQuit(testRootDir, testResults, true);
+ };
+
+ process.on("SIGINT", (s) => handleSignal(s));
+ process.on("SIGTERM", (s) => handleSignal(s));
+ //process.on("unhandledRejection", handleSignal);
+ //process.on("uncaughtException", handleSignal);
+
+ let suites: Set<string> | undefined;
+
+ if (spec.suiteSpec) {
+ suites = new Set(spec.suiteSpec.split(",").map((x) => x.trim()));
+ }
+
+ for (const [n, testCase] of allTests.entries()) {
+ const testName = getTestName(testCase);
+ if (spec.includePattern && !minimatch(testName, spec.includePattern)) {
+ continue;
+ }
+
+ if (suites) {
+ const ts = new Set(testCase.suites ?? []);
+ const intersection = new Set([...suites].filter((x) => ts.has(x)));
+ if (intersection.size === 0) {
+ continue;
+ }
+ } else {
+ if (testCase.excludeByDefault) {
+ continue;
+ }
+ }
+
+ if (spec.dryRun) {
+ console.log(`dry run: would run test ${testName}`);
+ continue;
+ }
+
+ const testInstr: RunTestChildInstruction = {
+ testName,
+ testRootDir,
+ };
+
+ const myFilename = url.fileURLToPath(import.meta.url);
+
+ currentChild = child_process.fork(myFilename, ["__TWCLI_TESTWORKER"], {
+ env: {
+ TWCLI_RUN_TEST_INSTRUCTION: JSON.stringify(testInstr),
+ ...process.env,
+ },
+ stdio: ["pipe", "pipe", "pipe", "ipc"],
+ });
+
+ const testDir = path.join(testRootDir, testName);
+ fs.mkdirSync(testDir, { recursive: true });
+
+ const harnessLogFilename = path.join(testRootDir, testName, "harness.log");
+ const harnessLogStream = fs.createWriteStream(harnessLogFilename);
+
+ if (spec.verbosity > 0) {
+ currentChild.stderr?.pipe(process.stderr);
+ currentChild.stdout?.pipe(process.stdout);
+ }
+
+ currentChild.stdout?.pipe(harnessLogStream);
+ currentChild.stderr?.pipe(harnessLogStream);
+
+ const defaultTimeout = 60000;
+ const testTimeoutMs = testCase.timeoutMs ?? defaultTimeout;
+
+ console.log(`running ${testName} with timeout ${testTimeoutMs}ms`);
+
+ const { token } = CancellationToken.timeout(testTimeoutMs);
+
+ const resultPromise: Promise<TestRunResult> = new Promise(
+ (resolve, reject) => {
+ let msg: TestRunResult | undefined;
+ currentChild!.on("message", (m) => {
+ if (token.isCancelled) {
+ return;
+ }
+ msg = m as TestRunResult;
+ });
+ currentChild!.on("exit", (code, signal) => {
+ if (token.isCancelled) {
+ return;
+ }
+ console.log(`process exited code=${code} signal=${signal}`);
+ if (signal) {
+ reject(new Error(`test worker exited with signal ${signal}`));
+ } else if (code != 0) {
+ reject(new Error(`test worker exited with code ${code}`));
+ } else if (!msg) {
+ reject(
+ new Error(
+ `test worker exited without giving back the test results`,
+ ),
+ );
+ } else {
+ resolve(msg);
+ }
+ });
+ currentChild!.on("error", (err) => {
+ if (token.isCancelled) {
+ return;
+ }
+ reject(err);
+ });
+ },
+ );
+
+ let result: TestRunResult;
+
+ try {
+ result = await token.racePromise(resultPromise);
+ } catch (e: any) {
+ console.error(`test ${testName} timed out`);
+ if (token.isCancelled) {
+ result = {
+ status: "fail",
+ reason: "timeout",
+ timeSec: testTimeoutMs / 1000,
+ name: testName,
+ };
+ currentChild.kill("SIGTERM");
+ } else {
+ throw Error(e);
+ }
+ }
+
+ harnessLogStream.close();
+
+ console.log(`parent: got result ${JSON.stringify(result)}`);
+
+ testResults.push(result);
+ }
+
+ reportAndQuit(testRootDir, testResults);
+}
+
+export function reportAndQuit(
+ testRootDir: string,
+ testResults: TestRunResult[],
+ interrupted: boolean = false,
+): never {
+ let numTotal = 0;
+ let numFail = 0;
+ let numSkip = 0;
+ let numPass = 0;
+
+ for (const result of testResults) {
+ numTotal++;
+ if (result.status === "fail") {
+ numFail++;
+ } else if (result.status === "skip") {
+ numSkip++;
+ } else if (result.status === "pass") {
+ numPass++;
+ }
+ }
+
+ const resultsFile = path.join(testRootDir, "results.json");
+ fs.writeFileSync(
+ path.join(testRootDir, "results.json"),
+ JSON.stringify({ testResults, interrupted }, undefined, 2),
+ );
+ if (interrupted) {
+ console.log("test suite was interrupted");
+ }
+ console.log(`See ${resultsFile} for details`);
+ console.log(`Skipped: ${numSkip}/${numTotal}`);
+ console.log(`Failed: ${numFail}/${numTotal}`);
+ console.log(`Passed: ${numPass}/${numTotal}`);
+
+ if (interrupted) {
+ process.exit(3);
+ } else if (numPass < numTotal - numSkip) {
+ process.exit(1);
+ } else {
+ process.exit(0);
+ }
+}
+
+export function getTestInfo(): TestInfo[] {
+ return allTests.map((x) => ({
+ name: getTestName(x),
+ suites: x.suites ?? [],
+ excludeByDefault: x.excludeByDefault ?? false,
+ }));
+}
+
+const runTestInstrStr = process.env["TWCLI_RUN_TEST_INSTRUCTION"];
+if (runTestInstrStr && process.argv.includes("__TWCLI_TESTWORKER")) {
+ // Test will call taler-wallet-cli, so we must not propagate this variable.
+ delete process.env["TWCLI_RUN_TEST_INSTRUCTION"];
+ const { testRootDir, testName } = JSON.parse(
+ runTestInstrStr,
+ ) as RunTestChildInstruction;
+ console.log(`running test ${testName} in worker process`);
+
+ process.on("disconnect", () => {
+ console.log("got disconnect from parent");
+ process.exit(3);
+ });
+
+ const runTest = async () => {
+ let testMain: TestMainFunction | undefined;
+ for (const t of allTests) {
+ if (getTestName(t) === testName) {
+ testMain = t;
+ break;
+ }
+ }
+
+ if (!process.send) {
+ console.error("can't communicate with parent");
+ process.exit(2);
+ }
+
+ if (!testMain) {
+ console.log(`test ${testName} not found`);
+ process.exit(2);
+ }
+
+ const testDir = path.join(testRootDir, testName);
+ console.log(`running test ${testName}`);
+ const gc = new GlobalTestState({
+ testDir,
+ });
+ const testResult = await runTestWithState(gc, testMain, testName);
+ process.send(testResult);
+ };
+
+ runTest()
+ .then(() => {
+ console.log(`test ${testName} finished in worker`);
+ if (shouldLingerInTest()) {
+ console.log("lingering ...");
+ return;
+ }
+ process.exit(0);
+ })
+ .catch((e) => {
+ console.log(e);
+ process.exit(1);
+ });
+}
diff --git a/packages/taler-harness/src/lint.ts b/packages/taler-harness/src/lint.ts
new file mode 100644
index 000000000..49fb9dc86
--- /dev/null
+++ b/packages/taler-harness/src/lint.ts
@@ -0,0 +1,534 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * The deployment linter implements checks for a deployment
+ * of the GNU Taler exchange. It is meant to help sysadmins
+ * when setting up an exchange.
+ *
+ * The linter does checks in the configuration and uses
+ * various tools of the exchange in test mode (-t).
+ *
+ * To be able to run the tools as the right user, the linter should be
+ * run as root.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ codecForExchangeKeysJson,
+ codecForKeysManagementResponse,
+ Configuration,
+ decodeCrock,
+} from "@gnu-taler/taler-util";
+import {
+ NodeHttpLib,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-wallet-core";
+import { URL } from "url";
+import { spawn } from "child_process";
+import { delayMs } from "./harness/harness.js";
+
+interface BasicConf {
+ mainCurrency: string;
+}
+
+interface PubkeyConf {
+ masterPublicKey: string;
+}
+
+const httpLib = new NodeHttpLib();
+
+interface ShellResult {
+ stdout: string;
+ stderr: string;
+ status: number;
+}
+
+interface LintContext {
+ /**
+ * Be more verbose.
+ */
+ verbose: boolean;
+
+ /**
+ * Always continue even after errors.
+ */
+ cont: boolean;
+
+ cfg: Configuration;
+
+ numErr: number;
+}
+
+/**
+ * Run a shell command, return stdout.
+ */
+export async function sh(
+ context: LintContext,
+ command: string,
+ env: { [index: string]: string | undefined } = process.env,
+): Promise<ShellResult> {
+ if (context.verbose) {
+ console.log("executing command:", command);
+ }
+ return new Promise((resolve, reject) => {
+ const stdoutChunks: Buffer[] = [];
+ const stderrChunks: Buffer[] = [];
+ const proc = spawn(command, {
+ stdio: ["inherit", "pipe", "pipe"],
+ shell: true,
+ env: env,
+ });
+ proc.stdout.on("data", (x) => {
+ if (x instanceof Buffer) {
+ stdoutChunks.push(x);
+ } else {
+ throw Error("unexpected data chunk type");
+ }
+ });
+ proc.stderr.on("data", (x) => {
+ if (x instanceof Buffer) {
+ stderrChunks.push(x);
+ } else {
+ throw Error("unexpected data chunk type");
+ }
+ });
+ proc.on("exit", (code, signal) => {
+ if (code != 0 && context.verbose) {
+ console.log(`child process exited (${code} / ${signal})`);
+ }
+ const bOut = Buffer.concat(stdoutChunks).toString("utf-8");
+ const bErr = Buffer.concat(stderrChunks).toString("utf-8");
+ resolve({
+ status: code ?? -1,
+ stderr: bErr,
+ stdout: bOut,
+ });
+ });
+ proc.on("error", () => {
+ reject(Error("Child process had error"));
+ });
+ });
+}
+
+function checkBasicConf(context: LintContext): BasicConf {
+ const cfg = context.cfg;
+ const currencyEntry = cfg.getString("taler", "currency");
+ let mainCurrency: string | undefined;
+
+ if (!currencyEntry.value) {
+ context.numErr++;
+ console.log("error: currency not defined in section TALER option CURRENCY");
+ console.log("Aborting further checks.");
+ process.exit(1);
+ } else {
+ mainCurrency = currencyEntry.value.toUpperCase();
+ }
+
+ if (mainCurrency === "KUDOS") {
+ console.log(
+ "warning: section TALER option CURRENCY contains toy currency value KUDOS",
+ );
+ }
+
+ const roundUnit = cfg.getAmount("taler", "currency_round_unit");
+ const ru = roundUnit.required();
+ if (ru.currency.toLowerCase() != mainCurrency.toLowerCase()) {
+ context.numErr++;
+ console.log(
+ "error: [TALER]/CURRENCY_ROUND_UNIT: currency does not match main currency",
+ );
+ }
+ return { mainCurrency };
+}
+
+function checkCoinConfig(context: LintContext, basic: BasicConf): void {
+ const cfg = context.cfg;
+ const coinPrefix1 = "COIN_";
+ const coinPrefix2 = "COIN-";
+ let numCoins = 0;
+
+ for (const secName of cfg.getSectionNames()) {
+ if (!(secName.startsWith(coinPrefix1) || secName.startsWith(coinPrefix2))) {
+ continue;
+ }
+ numCoins++;
+
+ // FIXME: check that section is well-formed
+ }
+
+ if (numCoins == 0) {
+ context.numErr++;
+ console.log(
+ "error: no coin denomination configured, please configure [coin-*] sections",
+ );
+ }
+}
+
+async function checkWireConfig(context: LintContext): Promise<void> {
+ const cfg = context.cfg;
+ const accountPrefix = "EXCHANGE-ACCOUNT-";
+ const accountCredentialsPrefix = "EXCHANGE-ACCOUNTCREDENTIALS-";
+
+ let accounts = new Set<string>();
+ let credentials = new Set<string>();
+
+ for (const secName of cfg.getSectionNames()) {
+ if (secName.startsWith(accountPrefix)) {
+ accounts.add(secName.slice(accountPrefix.length));
+ // FIXME: check settings
+ }
+
+ if (secName.startsWith(accountCredentialsPrefix)) {
+ credentials.add(secName.slice(accountCredentialsPrefix.length));
+ // FIXME: check settings
+ }
+ }
+
+ if (accounts.size === 0) {
+ context.numErr++;
+ console.log(
+ "error: No accounts configured (no sections EXCHANGE-ACCOUNT-*).",
+ );
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+
+ for (const acc of accounts) {
+ if (!credentials.has(acc)) {
+ console.log(
+ `warning: no credentials configured for exchange-account-${acc}`,
+ );
+ }
+ }
+
+ for (const acc of accounts) {
+ // test credit history
+ {
+ const res = await sh(
+ context,
+ "su -l --shell /bin/sh " +
+ `-c 'taler-exchange-wire-gateway-client -s exchange-accountcredentials-${acc} --credit-history' ` +
+ "taler-exchange-wire",
+ );
+ if (res.status != 0) {
+ context.numErr++;
+ console.log(res.stdout);
+ console.log(res.stderr);
+ console.log(
+ "error: Could not run taler-exchange-wire-gateway-client. Please review logs above.",
+ );
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+ }
+ }
+
+ // TWG client
+ {
+ const res = await sh(
+ context,
+ `su -l --shell /bin/sh -c 'taler-exchange-wirewatch -t' taler-exchange-wire`,
+ );
+ if (res.status != 0) {
+ context.numErr++;
+ console.log(res.stdout);
+ console.log(res.stderr);
+ console.log("error: Could not run wirewatch. Please review logs above.");
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+ }
+
+ // Wirewatch
+ {
+ const res = await sh(
+ context,
+ `su -l --shell /bin/sh -c 'taler-exchange-wirewatch -t' taler-exchange-wire`,
+ );
+ if (res.status != 0) {
+ context.numErr++;
+ console.log(res.stdout);
+ console.log(res.stderr);
+ console.log("error: Could not run wirewatch. Please review logs above.");
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+ }
+
+ // Closer
+ {
+ const res = await sh(
+ context,
+ `su -l --shell /bin/sh -c 'taler-exchange-closer -t' taler-exchange-closer`,
+ );
+ if (res.status != 0) {
+ context.numErr++;
+ console.log(res.stdout);
+ console.log(res.stderr);
+ console.log("error: Could not run closer. Please review logs above.");
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+ }
+}
+
+async function checkAggregatorConfig(context: LintContext) {
+ const res = await sh(
+ context,
+ "su -l --shell /bin/sh -c 'taler-exchange-aggregator -t' taler-exchange-aggregator",
+ );
+ if (res.status != 0) {
+ context.numErr++;
+ console.log(res.stdout);
+ console.log(res.stderr);
+ console.log("error: Could not run aggregator. Please review logs above.");
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+}
+
+async function checkCloserConfig(context: LintContext) {
+ const res = await sh(
+ context,
+ `su -l --shell /bin/sh -c 'taler-exchange-closer -t' taler-exchange-closer`,
+ );
+ if (res.status != 0) {
+ context.numErr++;
+ console.log(res.stdout);
+ console.log(res.stderr);
+ console.log("error: Could not run closer. Please review logs above.");
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+}
+
+function checkMasterPublicKeyConfig(context: LintContext): PubkeyConf {
+ const cfg = context.cfg;
+ const pub = cfg.getString("exchange", "master_public_key");
+
+ const pubDecoded = decodeCrock(pub.required());
+
+ if (pubDecoded.length != 32) {
+ context.numErr++;
+ console.log("error: invalid master public key");
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+
+ return {
+ masterPublicKey: pub.required(),
+ };
+}
+
+export async function checkExchangeHttpd(
+ context: LintContext,
+ pubConf: PubkeyConf,
+): Promise<void> {
+ const cfg = context.cfg;
+ const baseUrlEntry = cfg.getString("exchange", "base_url");
+
+ if (!baseUrlEntry.isDefined) {
+ context.numErr++;
+ console.log(
+ "error: configuration needs to specify section EXCHANGE option BASE_URL",
+ );
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+
+ const baseUrl = baseUrlEntry.required();
+
+ if (!baseUrl.startsWith("http")) {
+ context.numErr++;
+ console.log(
+ "error: section EXCHANGE option BASE_URL needs to be an http or https URL",
+ );
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+
+ if (!baseUrl.endsWith("/")) {
+ context.numErr++;
+ console.log(
+ "error: section EXCHANGE option BASE_URL needs to end with a slash",
+ );
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+
+ if (!baseUrl.startsWith("https://")) {
+ console.log(
+ "warning: section EXCHANGE option BASE_URL: it is recommended to serve the exchange via HTTPS",
+ );
+ }
+
+ {
+ const mgmtUrl = new URL("management/keys", baseUrl);
+ const resp = await httpLib.get(mgmtUrl.href);
+
+ const futureKeys = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForKeysManagementResponse(),
+ );
+
+ if (futureKeys.future_denoms.length > 0) {
+ console.log(
+ `warning: exchange has denomination keys that need to be signed by the offline signing procedure`,
+ );
+ }
+
+ if (futureKeys.future_signkeys.length > 0) {
+ console.log(
+ `warning: exchange has signing keys that need to be signed by the offline signing procedure`,
+ );
+ }
+ }
+
+ // Check if we can use /keys already
+ {
+ const keysUrl = new URL("keys", baseUrl);
+
+ const resp = await Promise.race([httpLib.get(keysUrl.href), delayMs(2000)]);
+
+ if (!resp) {
+ context.numErr++;
+ console.log(
+ "error: request to /keys timed out. " +
+ "Make sure to sign and upload denomination and signing keys " +
+ "with taler-exchange-offline.",
+ );
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ } else {
+ const keys = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeKeysJson(),
+ );
+
+ if (keys.master_public_key !== pubConf.masterPublicKey) {
+ context.numErr++;
+ console.log(
+ "error: master public key of exchange does not match public key of live exchange",
+ );
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ }
+ }
+ }
+
+ // Check /wire
+ {
+ const keysUrl = new URL("wire", baseUrl);
+
+ const resp = await Promise.race([httpLib.get(keysUrl.href), delayMs(2000)]);
+
+ if (!resp) {
+ context.numErr++;
+ console.log(
+ "error: request to /wire timed out. " +
+ "Make sure to sign and upload accounts and wire fees " +
+ "using the taler-exchange-offline tool.",
+ );
+ if (!context.cont) {
+ console.log("Aborting further checks.");
+ process.exit(1);
+ }
+ } else {
+ if (resp.status !== 200) {
+ console.log(
+ "error: Can't access exchange /wire. Please check " +
+ "the logs of taler-exchange-httpd for further information.",
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Do some basic checks in the configuration of a Taler deployment.
+ */
+export async function lintExchangeDeployment(
+ verbose: boolean,
+ cont: boolean,
+): Promise<void> {
+ if (process.getuid!() != 0) {
+ console.log(
+ "warning: the exchange deployment linter is designed to be run as root",
+ );
+ }
+
+ const cfg = Configuration.load();
+
+ const context: LintContext = {
+ cont,
+ verbose,
+ cfg,
+ numErr: 0,
+ };
+
+ const basic = checkBasicConf(context);
+
+ checkCoinConfig(context, basic);
+
+ await checkWireConfig(context);
+
+ await checkAggregatorConfig(context);
+
+ await checkCloserConfig(context);
+
+ const pubConf = checkMasterPublicKeyConfig(context);
+
+ await checkExchangeHttpd(context, pubConf);
+
+ if (context.numErr == 0) {
+ console.log("Linting completed without errors.");
+ process.exit(0);
+ } else {
+ console.log(`Linting completed with ${context.numErr} errors.`);
+ process.exit(1);
+ }
+}
diff --git a/packages/taler-harness/tsconfig.json b/packages/taler-harness/tsconfig.json
new file mode 100644
index 000000000..447d3f946
--- /dev/null
+++ b/packages/taler-harness/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compileOnSave": true,
+ "compilerOptions": {
+ "composite": true,
+ "target": "ES2018",
+ "module": "ESNext",
+ "moduleResolution": "Node16",
+ "sourceMap": true,
+ "lib": ["es6"],
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "strict": true,
+ "strictPropertyInitialization": false,
+ "outDir": "lib",
+ "noImplicitAny": true,
+ "noImplicitThis": true,
+ "incremental": true,
+ "esModuleInterop": true,
+ "importHelpers": true,
+ "rootDir": "src",
+ "baseUrl": "./src",
+ "typeRoots": ["./node_modules/@types"]
+ },
+ "include": ["src/**/*"],
+ "references": [
+ {
+ "path": "../taler-wallet-core/"
+ },
+ {
+ "path": "../taler-util/"
+ }
+ ]
+}