summaryrefslogtreecommitdiff
path: root/packages/taler-harness/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-harness/src')
-rw-r--r--packages/taler-harness/src/bench1.ts185
-rw-r--r--packages/taler-harness/src/bench2.ts186
-rw-r--r--packages/taler-harness/src/bench3.ts207
-rw-r--r--packages/taler-harness/src/benchMerchantIDGenerator.ts84
-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.ts271
-rw-r--r--packages/taler-harness/src/harness/harness.ts2268
-rw-r--r--packages/taler-harness/src/harness/helpers.ts954
-rw-r--r--packages/taler-harness/src/harness/sync.ts119
-rw-r--r--packages/taler-harness/src/import-meta-url.js2
-rw-r--r--packages/taler-harness/src/index.ts1322
-rw-r--r--packages/taler-harness/src/integrationtests/test-age-restrictions-deposit.ts114
-rw-r--r--packages/taler-harness/src/integrationtests/test-age-restrictions-merchant.ts174
-rw-r--r--packages/taler-harness/src/integrationtests/test-age-restrictions-mixed-merchant.ts129
-rw-r--r--packages/taler-harness/src/integrationtests/test-age-restrictions-peer.ts134
-rw-r--r--packages/taler-harness/src/integrationtests/test-bank-api.ts169
-rw-r--r--packages/taler-harness/src/integrationtests/test-claim-loop.ts82
-rw-r--r--packages/taler-harness/src/integrationtests/test-clause-schnorr.ts104
-rw-r--r--packages/taler-harness/src/integrationtests/test-currency-scope.ts191
-rw-r--r--packages/taler-harness/src/integrationtests/test-denom-lost.ts81
-rw-r--r--packages/taler-harness/src/integrationtests/test-denom-unoffered.ts164
-rw-r--r--packages/taler-harness/src/integrationtests/test-deposit.ts122
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-deposit.ts159
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts302
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-management.ts82
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-purse.ts224
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts287
-rw-r--r--packages/taler-harness/src/integrationtests/test-fee-regression.ts241
-rw-r--r--packages/taler-harness/src/integrationtests/test-forced-selection.ts86
-rw-r--r--packages/taler-harness/src/integrationtests/test-kyc.ts451
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-bank.ts229
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts264
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-instances-delete.ts137
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts179
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-instances.ts205
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-longpolling.ts165
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-refund-api.ts302
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts631
-rw-r--r--packages/taler-harness/src/integrationtests/test-multiexchange.ts172
-rw-r--r--packages/taler-harness/src/integrationtests/test-otp.ts119
-rw-r--r--packages/taler-harness/src/integrationtests/test-pay-paid.ts213
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-abort.ts164
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-claim.ts121
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-deleted.ts104
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-expired.ts132
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-fault.ts234
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-forgettable.ts86
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-idempotency.ts130
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-multiple.ts193
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-share.ts309
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-template.ts125
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-transient.ts179
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-zero.ts69
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment.ts88
-rw-r--r--packages/taler-harness/src/integrationtests/test-paywall-flow.ts250
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-pull-large.ts194
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-push-large.ts177
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-repair.ts213
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts285
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-to-peer-push.ts264
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund-auto.ts113
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund-gone.ts139
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund-incremental.ts203
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund.ts149
-rw-r--r--packages/taler-harness/src/integrationtests/test-revocation.ts267
-rw-r--r--packages/taler-harness/src/integrationtests/test-simple-payment.ts59
-rw-r--r--packages/taler-harness/src/integrationtests/test-stored-backups.ts113
-rw-r--r--packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts234
-rw-r--r--packages/taler-harness/src/integrationtests/test-timetravel-withdraw.ts109
-rw-r--r--packages/taler-harness/src/integrationtests/test-tos-format.ts101
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-backup-basic.ts189
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-backup-doublespend.ts183
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-balance-notifications.ts114
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-balance-zero.ts64
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-balance.ts130
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts150
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts142
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts177
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-push.ts149
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts101
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-config.ts67
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-cryptoworker.ts42
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-dbless.ts160
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-dd48.ts206
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts176
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-dev-experiments.ts48
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts166
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-gendb.ts111
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts166
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-notifications.ts195
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-observability.ts141
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts107
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-refresh.ts201
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-wirefees.ts210
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallettesting.ts247
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-abort-bank.ts77
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-amount.ts94
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts187
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts303
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts101
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts192
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-handover.ts194
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts140
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts103
-rw-r--r--packages/taler-harness/src/integrationtests/testrunner.ts599
-rw-r--r--packages/taler-harness/src/lint.ts536
-rw-r--r--packages/taler-harness/src/sandcastle-config.ts10
109 files changed, 22820 insertions, 0 deletions
diff --git a/packages/taler-harness/src/bench1.ts b/packages/taler-harness/src/bench1.ts
new file mode 100644
index 000000000..d260ea731
--- /dev/null
+++ b/packages/taler-harness/src/bench1.ts
@@ -0,0 +1,185 @@
+/*
+ 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 {
+ AmountString,
+ buildCodecForObject,
+ codecForBoolean,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+ j2s,
+ Logger,
+} from "@gnu-taler/taler-util";
+import {
+ AccessStats,
+ createNativeWalletHost2,
+ Wallet,
+ WalletApiOperation,
+} from "@gnu-taler/taler-wallet-core";
+import { harnessHttpLib } 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 runBench1(configJson: any): Promise<void> {
+ const logger = new Logger("Bench1");
+
+ // Validate the configuration file for this benchmark.
+ const b1conf = codecForBench1Config().decode(configJson);
+
+ 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)");
+ }
+
+ 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) {
+ await wallet.client.call(WalletApiOperation.Shutdown, {});
+ console.log("wallet DB stats", j2s(getDbStats!()));
+ }
+
+ const res = await createNativeWalletHost2({
+ // No persistent DB storage.
+ persistentStoragePath: undefined,
+ httpLib: harnessHttpLib,
+ });
+ wallet = res.wallet;
+ getDbStats = res.getDbStats;
+ await wallet.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ insecureTrustExchange: trustExchange,
+ },
+ features: {},
+ },
+ });
+ }
+
+ logger.trace(`Starting withdrawal amount=${withdrawAmount}`);
+ let start = Date.now();
+
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ amount: (b1conf.currency + ":" + withdrawAmount) as AmountString,
+ corebankApiBaseUrl: b1conf.bank,
+ exchangeBaseUrl: b1conf.exchange,
+ });
+
+ await wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
+
+ 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") as AmountString,
+ depositPaytoUri: b1conf.payto,
+ });
+
+ await wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
+
+ logger.info(`Finished deposit amount=10 time=${Date.now() - start}`);
+ }
+ }
+ }
+
+ await wallet.client.call(WalletApiOperation.Shutdown, {});
+ 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..90924caec
--- /dev/null
+++ b/packages/taler-harness/src/bench2.ts
@@ -0,0 +1,186 @@
+/*
+ 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 {
+ AmountString,
+ buildCodecForObject,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+ Logger,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import {
+ applyRunConfigDefaults,
+ CryptoDispatcher,
+ SynchronousCryptoWorkerFactoryPlain,
+ Wallet,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ checkReserve,
+ createTestingReserve,
+ depositCoin,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ refreshCoin,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core/dbless";
+
+/**
+ * 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 SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptoDisp.cryptoApi;
+
+ const http = createPlatformHttpLib({
+ enableThrottling: false,
+ });
+
+ const numIter = benchConf.iterations ?? 1;
+ const numDeposits = benchConf.deposits ?? 5;
+
+ const reserveAmount = (numDeposits + 1) * 10;
+
+ const defaultConfig = applyRunConfigDefaults();
+
+ 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 createTestingReserve({
+ amount: `${curr}:${reserveAmount}`,
+ exchangeInfo,
+ corebankApiBaseUrl: 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` as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ });
+
+ 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` as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: benchConf.exchange,
+ http,
+ depositPayto: benchConf.payto,
+ });
+
+ const refreshDenoms = [
+ findDenomOrThrow(exchangeInfo, `${curr}:1` as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ }),
+ findDenomOrThrow(exchangeInfo, `${curr}:1` as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ }),
+ ];
+
+ 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..ddf763c5b
--- /dev/null
+++ b/packages/taler-harness/src/bench3.ts
@@ -0,0 +1,207 @@
+/*
+ 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 {
+ AmountString,
+ buildCodecForObject,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+ j2s,
+ Logger,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import {
+ AccessStats,
+ createNativeWalletHost2,
+ Wallet,
+ WalletApiOperation,
+} 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 = createPlatformHttpLib({
+ enableThrottling: 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)");
+ }
+ 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) {
+ await wallet.client.call(WalletApiOperation.Shutdown, {});
+ console.log("wallet DB stats", j2s(getDbStats!()));
+ }
+
+ const res = await createNativeWalletHost2({
+ // No persistent DB storage.
+ persistentStoragePath: undefined,
+ httpLib: myHttpLib,
+ });
+ wallet = res.wallet;
+ getDbStats = res.getDbStats;
+ await wallet.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ features: {},
+ testing: {
+ insecureTrustExchange: trustExchange,
+ },
+ },
+ });
+ }
+
+ logger.trace(`Starting withdrawal amount=${withdrawAmount}`);
+ let start = Date.now();
+
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ amount: (b3conf.currency + ":" + withdrawAmount) as AmountString,
+ corebankApiBaseUrl: b3conf.bank,
+ exchangeBaseUrl: b3conf.exchange,
+ });
+
+ await wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
+
+ 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") as AmountString,
+ depositPaytoUri: payto,
+ });
+
+ await wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
+
+ logger.info(`Finished deposit amount=10 time=${Date.now() - start}`);
+ }
+ }
+
+ await wallet.client.call(WalletApiOperation.Shutdown, {});
+ 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..89b26dc81
--- /dev/null
+++ b/packages/taler-harness/src/benchMerchantIDGenerator.ts
@@ -0,0 +1,84 @@
+/*
+ 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..bb2cb8c47
--- /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,
+ generateRandomPayto,
+} 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("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..2d5e719b0
--- /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}`.padStart(2, "0");
+
+ 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..f4d7fc4b9
--- /dev/null
+++ b/packages/taler-harness/src/harness/faultInjection.ts
@@ -0,0 +1,271 @@
+/*
+ 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;
+ // These are only used when the request is dropped
+ substituteResponseBody?: Buffer;
+ substituteResponseStatusCode?: number;
+ substituteResponseHeaders?: Record<string, string | string[] | undefined>;
+}
+
+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) {
+ if (faultReqContext.substituteResponseStatusCode) {
+ const statusCode = faultReqContext.substituteResponseStatusCode;
+ res.writeHead(
+ statusCode,
+ http.STATUS_CODES[statusCode],
+ faultReqContext.substituteResponseHeaders,
+ );
+ res.write(faultReqContext.substituteResponseBody);
+ res.end();
+ } else {
+ 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..136ec3d15
--- /dev/null
+++ b/packages/taler-harness/src/harness/harness.ts
@@ -0,0 +1,2268 @@
+/*
+ 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 {
+ AccountRestriction,
+ AmountJson,
+ Amounts,
+ Configuration,
+ CoreApiResponse,
+ Duration,
+ EddsaKeyPair,
+ Logger,
+ MerchantInstanceConfig,
+ PartialMerchantInstanceConfig,
+ PaytoString,
+ TalerCorebankApiClient,
+ TalerError,
+ TalerMerchantApi,
+ WalletNotification,
+ createEddsaKeyPair,
+ eddsaGetPublic,
+ encodeCrock,
+ hash,
+ j2s,
+ openPromise,
+ parsePaytoUri,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ createPlatformHttpLib,
+ expectSuccessResponseOrThrow,
+} from "@gnu-taler/taler-util/http";
+import {
+ WalletCoreApiClient,
+ WalletCoreRequestType,
+ WalletCoreResponseType,
+ WalletOperations,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ RemoteWallet,
+ WalletNotificationWaiter,
+ createRemoteWallet,
+ getClientFromRemoteWallet,
+ makeNotificationWaiter,
+} from "@gnu-taler/taler-wallet-core/remote";
+import { deepStrictEqual } from "assert";
+import { ChildProcess, spawn } from "child_process";
+import * as fs from "fs";
+import * as http from "http";
+import * as net from "node:net";
+import * as path from "path";
+import * as readline from "readline";
+import { CoinConfig } from "./denomStructures.js";
+
+const logger = new Logger("harness.ts");
+
+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;
+}
+
+class CommandError extends Error {
+ constructor(
+ public message: string,
+ public logName: string,
+ public command: string,
+ public args: string[],
+ public env: Env,
+ public code: number | null,
+ ) {
+ super(message);
+ }
+}
+interface Env {
+ [index: string]: string | undefined;
+}
+/**
+ * Run a shell command, return stdout.
+ */
+export async function sh(
+ t: GlobalTestState,
+ logName: string,
+ command: string,
+ env: Env = process.env,
+): Promise<string> {
+ logger.trace(`running command ${command}`);
+ return new Promise((resolve, reject) => {
+ const stdoutChunks: Buffer[] = [];
+ const proc = spawn(command, {
+ stdio: ["inherit", "pipe", "pipe"],
+ shell: true,
+ 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 ${logName} exited (${code} / ${signal})`);
+ if (code != 0) {
+ reject(
+ new CommandError(
+ `Unexpected exit code ${code}`,
+ logName,
+ command,
+ [],
+ env,
+ code,
+ ),
+ );
+ return;
+ }
+ const b = Buffer.concat(stdoutChunks).toString("utf-8");
+ resolve(b);
+ });
+ proc.on("error", (err) => {
+ reject(
+ new CommandError(
+ "Child process had error:" + err.message,
+ logName,
+ command,
+ [],
+ env,
+ null,
+ ),
+ );
+ });
+ });
+}
+
+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.trace(`child process exited (${code} / ${signal})`);
+ if (code != 0) {
+ reject(
+ new CommandError(
+ `Unexpected exit code ${code}`,
+ logName,
+ command,
+ [],
+ env,
+ code,
+ ),
+ );
+ return;
+ }
+ const b = Buffer.concat(stdoutChunks).toString("utf-8");
+ resolve(b);
+ });
+ proc.on("error", (err) => {
+ reject(
+ new CommandError(
+ "Child process had error:" + err.message,
+ logName,
+ command,
+ [],
+ env,
+ null,
+ ),
+ );
+ });
+ });
+}
+
+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`,
+ );
+ }
+
+ 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.trace(`spawned process (${logName}) with pid ${proc.pid}`);
+ proc.on("error", (err) => {
+ logger.warn(`could not start process (${command})`, err);
+ });
+ proc.on("exit", (code, signal) => {
+ if (code == 0 && signal == null) {
+ logger.trace(`process ${logName} exited with success`);
+ } else {
+ 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.trace("refusing to shut down, lingering was requested");
+ return;
+ }
+ this.inShutdown = true;
+ logger.trace("shutting down");
+ for (const s of this.servers) {
+ s.close();
+ s.removeAllListeners();
+ }
+ for (const p of this.procs) {
+ if (p.proc.exitCode == null) {
+ logger.trace(`killing process ${p.proc.pid}`);
+ p.proc.kill("SIGTERM");
+ await p.wait();
+ }
+ }
+ }
+
+ /**
+ * Log that the test arrived a certain step.
+ *
+ * The step name should be unique across the whole
+ */
+ logStep(stepName: string): void {
+ // Now we just log, later we may report the steps that were done
+ // to easily see where the test hangs.
+ console.info(`STEP: ${stepName}`);
+ }
+}
+
+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 interface SetupDbOpts {
+ nameSuffix?: string;
+}
+
+export async function setupDb(
+ t: GlobalTestState,
+ opts: SetupDbOpts = {},
+): Promise<DbInfo> {
+ let dbname: string;
+ if (!opts.nameSuffix) {
+ dbname = "taler-integrationtest";
+ } else {
+ dbname = `taler-integrationtest-${opts.nameSuffix}`;
+ }
+ try {
+ await runCommand(t, "dropdb", "dropdb", [dbname]);
+ } catch (e: any) {
+ logger.warn(`dropdb failed: ${e.toString()}`);
+ }
+ await runCommand(t, "createdb", "createdb", [dbname]);
+ return {
+ connStr: `postgres:///${dbname}`,
+ dbname,
+ };
+}
+
+/**
+ * Make sure that the taler-integrationtest-shared database exists.
+ * Don't delete it if it already exists.
+ */
+export async function setupSharedDb(t: GlobalTestState): Promise<DbInfo> {
+ const dbname = "taler-integrationtest-shared";
+ const databases = await runCommand(t, "list-dbs", "psql", ["-Aqtl"]);
+ if (databases.indexOf("taler-integrationtest-shared") < 0) {
+ await runCommand(t, "createdb", "createdb", [dbname]);
+ }
+ return {
+ connStr: `postgres:///${dbname}`,
+ dbname,
+ };
+}
+
+export interface BankConfig {
+ currency: string;
+ httpPort: number;
+ database: string;
+ allowRegistrations: boolean;
+ maxDebt?: string;
+ overrideTestDir?: string;
+}
+
+export interface FakeBankConfig {
+ currency: string;
+ httpPort: number;
+}
+
+/**
+ * @param name additional component name, needed when launching multiple instances of the same component
+ */
+function setTalerPaths(config: Configuration, home: string, name?: 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 extraName = name != null ? `${name}-` : "";
+ const runDir = fs.mkdtempSync(`/tmp/taler-test-${extraName}`);
+ 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();
+ }
+}
+
+function backoffStart(): number {
+ return 10;
+}
+
+function backoffIncrement(n: number): number {
+ return Math.min(Math.floor(n * 1.5), 1000);
+}
+
+/**
+ * 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`);
+ }
+ let nextDelay = backoffStart();
+ while (true) {
+ try {
+ logger.trace(`pinging ${serviceName} at ${url}`);
+ const resp = await harnessHttpLib.fetch(url);
+ if (resp.status !== 200) {
+ throw Error("non-200 status code");
+ }
+ logger.trace(`service ${serviceName} available`);
+ return;
+ } catch (e: any) {
+ logger.warn(`service ${serviceName} not ready:`, e.toString());
+ logger.info(`waiting ${nextDelay}ms on ${serviceName}`);
+ await delayMs(nextDelay);
+ nextDelay = backoffIncrement(nextDelay);
+ }
+ 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,
+ ) {}
+}
+
+export interface HarnessExchangeBankAccount {
+ accountName: string;
+ accountPassword: string;
+ accountPaytoUri: string;
+ wireGatewayApiBaseUrl: string;
+
+ conversionUrl?: string;
+
+ debitRestrictions?: AccountRestriction[];
+ creditRestrictions?: AccountRestriction[];
+
+ /**
+ * If set, the harness will not automatically configure the wire fee for this account.
+ */
+ skipWireFeeCreation?: boolean;
+}
+
+/**
+ * Implementation of the bank service using the "taler-fakebank-run" tool.
+ */
+export class FakebankService
+ extends BankServiceBase
+ implements BankServiceHandle
+{
+ proc: ProcessWrapper | undefined;
+
+ http = createPlatformHttpLib({ enableThrottling: false });
+
+ // We store "created" accounts during setup and
+ // register them after startup.
+ private accounts: {
+ accountName: string;
+ accountPassword: string;
+ }[] = [];
+
+ /**
+ * Create a new fakebank service handle.
+ *
+ * First generates the configuration for the fakebank and
+ * then creates a fakebank handle, but doesn't start the fakebank
+ * service yet.
+ */
+ static async create(
+ gc: GlobalTestState,
+ bc: BankConfig,
+ ): Promise<FakebankService> {
+ const config = new Configuration();
+ const testDir = bc.overrideTestDir ?? gc.testDir;
+ setTalerPaths(config, 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 = testDir + "/bank.conf";
+ config.writeTo(cfgFilename, { excludeDefaults: true });
+
+ return new FakebankService(gc, bc, cfgFilename);
+ }
+
+ static fromExistingConfig(
+ gc: GlobalTestState,
+ opts: { overridePath?: string },
+ ): FakebankService {
+ const testDir = opts.overridePath ?? gc.testDir;
+ const cfgFilename = testDir + `/bank.conf`;
+ const config = Configuration.load(cfgFilename);
+ const bc: BankConfig = {
+ allowRegistrations:
+ config.getYesNo("bank", "allow_registrations").orUndefined() ?? true,
+ currency: config.getString("taler", "currency").required(),
+ database: "none",
+ httpPort: config.getNumber("bank", "http_port").required(),
+ maxDebt: config.getString("bank", "max_debt").required(),
+ };
+ 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.writeTo(this.configFile, { excludeDefaults: true });
+ }
+
+ get baseUrl(): string {
+ return `http://localhost:${this.bankConfig.httpPort}/`;
+ }
+
+ get corebankApiBaseUrl(): string {
+ return this.baseUrl;
+ }
+
+ // FIXME: Why do we have this function at all?
+ // We now have a unified corebank API, we should just use that
+ // to create bank accounts, also for the exchange.
+ async createExchangeAccount(
+ accountName: string,
+ password: string,
+ ): Promise<HarnessExchangeBankAccount> {
+ this.accounts.push({
+ accountName,
+ accountPassword: password,
+ });
+ return {
+ accountName: accountName,
+ accountPassword: password,
+ accountPaytoUri: generateRandomPayto(accountName),
+ wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/accounts/${accountName}/taler-wire-gateway/`,
+ };
+ }
+
+ 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();
+ const bankClient = new TalerCorebankApiClient(this.corebankApiBaseUrl);
+ for (const acc of this.accounts) {
+ await bankClient.registerAccount(acc.accountName, acc.accountPassword);
+ }
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `http://localhost:${this.bankConfig.httpPort}/config`;
+ await pingProc(this.proc, url, "bank");
+ }
+}
+
+/**
+ * Implementation of the bank service using the libeufin-bank implementation.
+ */
+export class LibeufinBankService
+ extends BankServiceBase
+ implements BankServiceHandle
+{
+ proc: ProcessWrapper | undefined;
+
+ http = createPlatformHttpLib({ enableThrottling: false });
+
+ // We store "created" accounts during setup and
+ // register them after startup.
+ private accounts: {
+ accountName: string;
+ accountPassword: string;
+ }[] = [];
+
+ /**
+ * Create a new fakebank service handle.
+ *
+ * First generates the configuration for the fakebank and
+ * then creates a fakebank handle, but doesn't start the fakebank
+ * service yet.
+ */
+ static async create(
+ gc: GlobalTestState,
+ bc: BankConfig,
+ ): Promise<LibeufinBankService> {
+ const config = new Configuration();
+ const testDir = bc.overrideTestDir ?? gc.testDir;
+ setTalerPaths(config, testDir + "/talerhome");
+ config.setString("libeufin-bankdb-postgres", "config", bc.database);
+ config.setString("libeufin-bank", "currency", bc.currency);
+ config.setString("libeufin-bank", "port", `${bc.httpPort}`);
+ config.setString("libeufin-bank", "serve", "tcp");
+ config.setString("libeufin-bank", "wire_type", "x-taler-bank");
+ config.setString(
+ "libeufin-bank",
+ "x_taler_bank_payto_hostname",
+ "localhost",
+ );
+ config.setString(
+ "libeufin-bank",
+ "default_debt_limit",
+ bc.maxDebt ?? `${bc.currency}:100`,
+ );
+ config.setString(
+ "libeufin-bank",
+ "DEFAULT_DEBT_LIMIT",
+ `${bc.currency}:100`,
+ );
+ config.setString(
+ "libeufin-bank",
+ "registration_bonus",
+ `${bc.currency}:100`,
+ );
+ const cfgFilename = testDir + "/bank.conf";
+ config.writeTo(cfgFilename, { excludeDefaults: true });
+
+ return new LibeufinBankService(gc, bc, cfgFilename);
+ }
+
+ static fromExistingConfig(
+ gc: GlobalTestState,
+ opts: { overridePath?: string },
+ ): FakebankService {
+ const testDir = opts.overridePath ?? gc.testDir;
+ const cfgFilename = testDir + `/bank.conf`;
+ const config = Configuration.load(cfgFilename);
+ const bc: BankConfig = {
+ allowRegistrations:
+ config.getYesNo("libeufin-bank", "allow_registrations").orUndefined() ??
+ true,
+ currency: config.getString("libeufin-bank", "currency").required(),
+ database: config
+ .getString("libeufin-bankdb-postgres", "config")
+ .required(),
+ httpPort: config.getNumber("libeufin-bank", "port").required(),
+ maxDebt: config
+ .getString("libeufin-bank", "DEFAULT_DEBT_LIMIT")
+ .required(),
+ };
+ return new FakebankService(gc, bc, cfgFilename);
+ }
+
+ setSuggestedExchange(e: ExchangeServiceInterface) {
+ if (!!this.proc) {
+ throw Error("Can't set suggested exchange while bank is running.");
+ }
+ const config = Configuration.load(this.configFile);
+ config.setString(
+ "libeufin-bank",
+ "suggested_withdrawal_exchange",
+ e.baseUrl,
+ );
+ config.writeTo(this.configFile, { excludeDefaults: true });
+ }
+
+ get baseUrl(): string {
+ return `http://localhost:${this.bankConfig.httpPort}/`;
+ }
+
+ get corebankApiBaseUrl(): string {
+ return this.baseUrl;
+ }
+
+ get port() {
+ return this.bankConfig.httpPort;
+ }
+
+ async start(): Promise<void> {
+ logger.info("starting libeufin-bank");
+ if (this.proc) {
+ logger.info("libeufin-bank already running, not starting again");
+ return;
+ }
+
+ await sh(
+ this.globalTestState,
+ "libeufin-bank-dbinit",
+ `libeufin-bank dbinit -r -c "${this.configFile}"`,
+ );
+
+ await sh(
+ this.globalTestState,
+ "libeufin-bank-passwd",
+ `libeufin-bank passwd -c "${this.configFile}" admin adminpw`,
+ );
+
+ await sh(
+ this.globalTestState,
+ "libeufin-bank-edit-account",
+ `libeufin-bank edit-account -c "${this.configFile}" admin --debit_threshold=${this.bankConfig.currency}:1000`,
+ );
+
+ this.proc = this.globalTestState.spawnService(
+ "libeufin-bank",
+ ["serve", "-c", this.configFile],
+ "libeufin-bank-httpd",
+ );
+ await this.pingUntilAvailable();
+ const bankClient = new TalerCorebankApiClient(this.corebankApiBaseUrl);
+ for (const acc of this.accounts) {
+ await bankClient.registerAccount(acc.accountName, acc.accountPassword);
+ }
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `http://localhost:${this.bankConfig.httpPort}/config`;
+ await pingProc(this.proc, url, "libeufin-bank");
+ }
+}
+
+// Use libeufin bank instead of pybank.
+export const useLibeufinBank = process.env["WITH_LIBEUFIN"] === "1";
+
+export interface BankServiceHandle {
+ readonly corebankApiBaseUrl: string;
+ readonly http: HttpRequestLibrary;
+
+ setSuggestedExchange(exchange: ExchangeService, exchangePayto: string): void;
+ start(): Promise<void>;
+ pingUntilAvailable(): Promise<void>;
+}
+
+export type BankService = BankServiceHandle;
+export const BankService = useLibeufinBank
+ ? LibeufinBankService
+ : FakebankService;
+
+export interface ExchangeConfig {
+ name: string;
+ currency: string;
+ roundUnit?: string;
+ httpPort: number;
+ database: string;
+ overrideTestDir?: string;
+ overrideWireFee?: 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,
+ opts: { overridePath?: string },
+ ) {
+ const testDir = opts.overridePath ?? gc.testDir;
+ const cfgFilename = 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-offline", "master_priv_file")
+ .required();
+ const eddsaPriv = fs.readFileSync(privFile);
+ const keyPair: EddsaKeyPair = {
+ eddsaPriv,
+ eddsaPub: eddsaGetPublic(eddsaPriv),
+ };
+ return new ExchangeService(gc, ec, cfgFilename, keyPair);
+ }
+
+ private currentTimetravelOffsetMs: number | undefined;
+
+ private exchangeBankAccounts: HarnessExchangeBankAccount[] = [];
+
+ setTimetravel(tMs: number | undefined): void {
+ if (this.isRunning()) {
+ throw Error("can't set time travel while the exchange is running");
+ }
+ this.currentTimetravelOffsetMs = tMs;
+ }
+
+ private get timetravelArg(): string | undefined {
+ if (this.currentTimetravelOffsetMs != null) {
+ // Convert to microseconds
+ return `--timetravel=+${this.currentTimetravelOffsetMs * 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 seconds 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 runAggregatorOnceWithTimetravel(opts: {
+ timetravelMicroseconds: number;
+ }) {
+ let timetravelArgArr = [];
+ timetravelArgArr.push(`--timetravel=${opts.timetravelMicroseconds}`);
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-aggregator-once`,
+ "taler-exchange-aggregator",
+ [...timetravelArgArr, "-c", this.configFilename, "-t", "-y", "-LINFO"],
+ );
+ }
+
+ async runAggregatorOnce() {
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-aggregator-once`,
+ "taler-exchange-aggregator",
+ [
+ ...this.timetravelArgArr,
+ "-c",
+ this.configFilename,
+ "-t",
+ "-y",
+ "-LINFO",
+ ],
+ );
+ }
+
+ async runTransferOnce() {
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-transfer-once`,
+ "taler-exchange-transfer",
+ [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+
+ async runTransferOnceWithTimetravel(opts: {
+ timetravelMicroseconds: number;
+ }) {
+ let timetravelArgArr = [];
+ timetravelArgArr.push(`--timetravel=${opts.timetravelMicroseconds}`);
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-transfer-once`,
+ "taler-exchange-transfer",
+ [...timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+
+ /**
+ * Run the taler-exchange-expire command once in test mode.
+ */
+ async runExpireOnce() {
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-expire-once`,
+ "taler-exchange-expire",
+ [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+
+ changeConfig(f: (config: Configuration) => void) {
+ const config = Configuration.load(this.configFilename);
+ f(config);
+ config.writeTo(this.configFilename, { excludeDefaults: true });
+ }
+
+ static create(gc: GlobalTestState, e: ExchangeConfig) {
+ const testDir = e.overrideTestDir ?? gc.testDir;
+ const config = new Configuration();
+ setTalerPaths(config, `${testDir}/talerhome-exchange-${e.name}`, e.name);
+ config.setString("taler", "currency", e.currency);
+ // Required by the exchange but not really used yet.
+ config.setString("exchange", "aml_threshold", `${e.currency}:1000000`);
+ config.setString(
+ "taler",
+ "currency_round_unit",
+ e.roundUnit ?? `${e.currency}:0.01`,
+ );
+ // Set to a high value to not break existing test cases where the merchant
+ // would cover all fees.
+ config.setString("exchange", "STEFAN_ABS", `${e.currency}:1`);
+ config.setString("exchange", "STEFAN_LOG", `${e.currency}:1`);
+ 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", "base_url", `http://localhost:${e.httpPort}/`);
+ 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");
+
+ // FIXME: Remove once the exchange default config properly ships this.
+ config.setString("exchange", "EXPIRE_IDLE_SLEEP_INTERVAL", "1 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 });
+
+ if (fs.existsSync(masterPrivFile)) {
+ throw new Error(
+ "master priv file already exists, can't create new exchange config",
+ );
+ }
+
+ fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv));
+
+ const cfgFilename = testDir + `/exchange-${e.name}.conf`;
+ config.writeTo(cfgFilename, { excludeDefaults: true });
+ 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.writeTo(this.configFilename, { excludeDefaults: true });
+ }
+
+ addCoinConfigList(ccs: CoinConfig[]) {
+ const config = Configuration.load(this.configFilename);
+ ccs.forEach((cc) => setCoin(config, cc));
+ config.writeTo(this.configFilename, { excludeDefaults: true });
+ }
+
+ 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.writeTo(this.configFilename, { excludeDefaults: true });
+ }
+
+ get masterPub() {
+ return encodeCrock(this.keyPair.eddsaPub);
+ }
+
+ get port() {
+ return this.exchangeConfig.httpPort;
+ }
+
+ /**
+ * Run a function that modifies the existing exchange configuration.
+ * The modified exchange configuration will then be written to the
+ * file system.
+ */
+ async modifyConfig(
+ f: (config: Configuration) => Promise<void>,
+ ): Promise<void> {
+ const config = Configuration.load(this.configFilename);
+ await f(config);
+ config.writeTo(this.configFilename, { excludeDefaults: true });
+ }
+
+ async addBankAccount(
+ localName: string,
+ exchangeBankAccount: HarnessExchangeBankAccount,
+ ): Promise<void> {
+ this.exchangeBankAccounts.push(exchangeBankAccount);
+ 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.writeTo(this.configFilename, { excludeDefaults: true });
+ }
+
+ exchangeHttpProc: ProcessWrapper | undefined;
+ exchangeWirewatchProc: ProcessWrapper | undefined;
+
+ exchangeTransferProc: ProcessWrapper | undefined;
+ exchangeAggregatorProc: 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;
+ }
+
+ /**
+ * Stop the wirewatch service (which runs by default).
+ *
+ * Useful for some tests.
+ */
+ async stopWirewatch(): Promise<void> {
+ const wirewatch = this.exchangeWirewatchProc;
+ if (wirewatch) {
+ wirewatch.proc.kill("SIGTERM");
+ await wirewatch.wait();
+ this.exchangeWirewatchProc = undefined;
+ }
+ }
+
+ async stopAggregator(): Promise<void> {
+ const agg = this.exchangeAggregatorProc;
+ if (agg) {
+ agg.proc.kill("SIGTERM");
+ await agg.wait();
+ this.exchangeAggregatorProc = undefined;
+ }
+ }
+
+ async startWirewatch(): Promise<void> {
+ const wirewatch = this.exchangeWirewatchProc;
+ if (wirewatch) {
+ logger.warn("wirewatch already running");
+ } else {
+ this.internalCreateWirewatchProc();
+ }
+ }
+
+ async stop(): Promise<void> {
+ const wirewatch = this.exchangeWirewatchProc;
+ if (wirewatch) {
+ wirewatch.proc.kill("SIGTERM");
+ await wirewatch.wait();
+ this.exchangeWirewatchProc = undefined;
+ }
+ const aggregatorProc = this.exchangeAggregatorProc;
+ if (aggregatorProc) {
+ aggregatorProc.proc.kill("SIGTERM");
+ await aggregatorProc.wait();
+ this.exchangeAggregatorProc = undefined;
+ }
+ const transferProc = this.exchangeTransferProc;
+ if (transferProc) {
+ transferProc.proc.kill("SIGTERM");
+ await transferProc.wait();
+ this.exchangeTransferProc = 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 accountTargetTypes: Set<string> = new Set();
+
+ for (const acct of this.exchangeBankAccounts) {
+ const paytoUri = acct.accountPaytoUri;
+ const p = parsePaytoUri(paytoUri);
+ if (!p) {
+ throw Error(`invalid payto uri in exchange config: ${paytoUri}`);
+ }
+ const optArgs: string[] = [];
+ if (acct.conversionUrl != null) {
+ optArgs.push("conversion-url", acct.conversionUrl);
+ }
+
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ "enable-account",
+ paytoUri,
+ ...optArgs,
+ "upload",
+ ],
+ );
+
+ const accTargetType = p.targetType;
+
+ const covered = accountTargetTypes.has(p.targetType);
+ if (!covered && !acct.skipWireFeeCreation) {
+ const year = new Date().getFullYear();
+
+ for (let i = year; i < year + 5; i++) {
+ const wireFee =
+ this.exchangeConfig.overrideWireFee ??
+ `${this.exchangeConfig.currency}:0.01`;
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ "wire-fee",
+ // Year
+ `${i}`,
+ // Wire method
+ accTargetType,
+ // Wire fee
+ wireFee,
+ // Closing fee
+ `${this.exchangeConfig.currency}:0.01`,
+ "upload",
+ ],
+ );
+ accountTargetTypes.add(accTargetType);
+ }
+ }
+ }
+
+ 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}"`,
+ );
+ }
+
+ private internalCreateWirewatchProc() {
+ this.exchangeWirewatchProc = this.globalState.spawnService(
+ "taler-exchange-wirewatch",
+ [
+ "-c",
+ this.configFilename,
+ "--longpoll-timeout=5s",
+ ...this.timetravelArgArr,
+ ],
+ `exchange-wirewatch-${this.name}`,
+ );
+ }
+
+ private internalCreateAggregatorProc() {
+ this.exchangeAggregatorProc = this.globalState.spawnService(
+ "taler-exchange-aggregator",
+ ["-c", this.configFilename, ...this.timetravelArgArr],
+ `exchange-aggregator-${this.name}`,
+ );
+ }
+
+ private internalCreateTransferProc() {
+ this.exchangeTransferProc = this.globalState.spawnService(
+ "taler-exchange-transfer",
+ ["-c", this.configFilename, ...this.timetravelArgArr],
+ `exchange-transfer-${this.name}`,
+ );
+ }
+
+ async dbinit() {
+ await sh(
+ this.globalState,
+ "exchange-dbinit",
+ `taler-exchange-dbinit -c "${this.configFilename}"`,
+ );
+ }
+
+ async start(
+ opts: { skipDbinit?: boolean; skipKeyup?: boolean } = {},
+ ): Promise<void> {
+ if (this.isRunning()) {
+ throw Error("exchange is already running");
+ }
+
+ const skipDbinit = opts.skipDbinit ?? false;
+
+ if (!skipDbinit) {
+ await this.dbinit();
+ }
+
+ 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.internalCreateWirewatchProc();
+ this.internalCreateTransferProc();
+ this.internalCreateAggregatorProc();
+
+ this.exchangeHttpProc = this.globalState.spawnService(
+ "taler-exchange-httpd",
+ ["-LINFO", "-c", this.configFilename, ...this.timetravelArgArr],
+ `exchange-httpd-${this.name}`,
+ );
+
+ await this.pingUntilAvailable();
+
+ const skipKeyup = opts.skipKeyup ?? false;
+
+ if (!skipKeyup) {
+ await this.keyup();
+ } else {
+ logger.info("skipping 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;
+ overrideTestDir?: string;
+}
+
+export interface MerchantServiceInterface {
+ makeInstanceBaseUrl(instanceName?: string): string;
+ readonly port: number;
+ readonly name: string;
+}
+
+/**
+ * Default HTTP client handle for the integration test harness.
+ */
+export const harnessHttpLib = createPlatformHttpLib({
+ enableThrottling: false,
+});
+
+export class MerchantService implements MerchantServiceInterface {
+ static fromExistingConfig(
+ gc: GlobalTestState,
+ name: string,
+ opts: { overridePath?: string },
+ ) {
+ const testDir = opts.overridePath ?? gc.testDir;
+ const cfgFilename = 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 currentTimetravelOffsetMs: number | undefined;
+
+ private isRunning(): boolean {
+ return !!this.proc;
+ }
+
+ setTimetravel(t: number | undefined): void {
+ if (this.isRunning()) {
+ throw Error("can't set time travel while the exchange is running");
+ }
+ this.currentTimetravelOffsetMs = t;
+ }
+
+ private get timetravelArg(): string | undefined {
+ if (this.currentTimetravelOffsetMs != null) {
+ // Convert to microseconds
+ return `--timetravel=+${this.currentTimetravelOffsetMs * 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 dbinit() {
+ await runCommand(
+ this.globalState,
+ "merchant-dbinit",
+ "taler-merchant-dbinit",
+ ["-c", this.configFilename],
+ );
+ }
+
+ /**
+ * Start the merchant,
+ */
+ async start(opts: { skipDbinit?: boolean } = {}): Promise<void> {
+ const skipSetup = opts.skipDbinit ?? false;
+
+ if (!skipSetup) {
+ await this.dbinit();
+ }
+
+ 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 testDir = mc.overrideTestDir ?? gc.testDir;
+ const config = new Configuration();
+ config.setString("taler", "currency", mc.currency);
+
+ const cfgFilename = testDir + `/merchant-${mc.name}.conf`;
+ setTalerPaths(config, 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);
+ // Do not contact demo.taler.net exchange in tests
+ config.setString("merchant-exchange-kudos", "disabled", "yes");
+ config.writeTo(cfgFilename, { excludeDefaults: true });
+
+ 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.writeTo(this.configFilename, { excludeDefaults: true });
+ }
+
+ async addDefaultInstance(): Promise<void> {
+ return await this.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+ }
+
+ /**
+ * Add an instance together with a wire account.
+ */
+ async addInstanceWithWireAccount(
+ instanceConfig: PartialMerchantInstanceConfig,
+ ): Promise<void> {
+ if (!this.proc) {
+ throw Error("merchant must be running to add instance");
+ }
+ logger.info(`adding instance '${instanceConfig.id}'`);
+ const url = `http://localhost:${this.merchantConfig.httpPort}/management/instances`;
+ const auth = instanceConfig.auth ?? { method: "external" };
+
+ const body: MerchantInstanceConfig = {
+ auth,
+ id: instanceConfig.id,
+ name: instanceConfig.name,
+ address: instanceConfig.address ?? {},
+ jurisdiction: instanceConfig.jurisdiction ?? {},
+ // FIXME: In some tests, we might want to make this configurable
+ use_stefan: true,
+ default_wire_transfer_delay:
+ instanceConfig.defaultWireTransferDelay ??
+ Duration.toTalerProtocolDuration(
+ Duration.fromSpec({
+ days: 1,
+ }),
+ ),
+ default_pay_delay:
+ instanceConfig.defaultPayDelay ??
+ Duration.toTalerProtocolDuration(Duration.getForever()),
+ };
+ const resp = await harnessHttpLib.fetch(url, { method: "POST", body });
+ await expectSuccessResponseOrThrow(resp);
+
+ const accountCreateUrl = `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceConfig.id}/private/accounts`;
+ for (const paytoUri of instanceConfig.paytoUris) {
+ const accountReq: TalerMerchantApi.AccountAddDetails = {
+ payto_uri: paytoUri as PaytoString,
+ };
+ const acctResp = await harnessHttpLib.fetch(accountCreateUrl, {
+ method: "POST",
+ body: accountReq,
+ });
+ await expectSuccessResponseOrThrow(acctResp);
+ }
+ }
+
+ 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})`);
+ }
+}
+
+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 (${s}), terminating test ${testName}`,
+ );
+ gc.shutdownSync();
+ process.exit(1);
+ };
+
+ process.on("SIGINT", handleSignal);
+ process.on("SIGTERM", handleSignal);
+
+ process.on("unhandledRejection", (reason: unknown, promise: any) => {
+ logger.warn(
+ `**** received unhandled rejection (${reason}), terminating test ${testName}`,
+ );
+ logger.warn(`reason type: ${typeof reason}`);
+ gc.shutdownSync();
+ process.exit(1);
+ });
+ process.on("uncaughtException", (error, origin) => {
+ logger.warn(
+ `**** received uncaught exception (${error}), terminating test ${testName}`,
+ );
+ console.warn("stack", error.stack);
+ gc.shutdownSync();
+ process.exit(1);
+ });
+
+ try {
+ logger.info("running test in directory", gc.testDir);
+ await Promise.race([testMain(gc), p.promise]);
+ logger.info("completed test in directory", gc.testDir);
+ 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) {
+ if (e instanceof CommandError) {
+ console.error("FATAL: test failed for", e.logName);
+ const errorLog = fs.readFileSync(
+ path.join(gc.testDir, `${e.logName}-stderr.log`),
+ );
+ console.error(`${e.message}: "${e.command}"`);
+ console.error(errorLog.toString());
+ console.error(e);
+ } else if (e instanceof TalerError) {
+ console.error(
+ "FATAL: test failed",
+ e.message,
+ `error detail: ${j2s(e.errorDetail)}`,
+ );
+ console.error(e.stack);
+ } else {
+ console.error("FATAL: test failed with exception", e);
+ }
+ 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";
+}
+
+function tryUnixConnect(socketPath: string): Promise<void> {
+ return new Promise((resolve, reject) => {
+ const client = net.createConnection(socketPath);
+ client.on("error", (e) => {
+ reject(e);
+ });
+ client.on("connect", () => {
+ client.end();
+ resolve();
+ });
+ });
+}
+
+export interface WalletServiceOptions {
+ useInMemoryDb?: boolean;
+ /**
+ * Use a particular DB path instead of the default one in the
+ * test environment.
+ */
+ overrideDbPath?: string;
+ name: string;
+}
+
+/**
+ * A wallet service that listens on a unix domain socket for commands.
+ */
+export class WalletService {
+ walletProc: ProcessWrapper | undefined;
+
+ private internalDbPath: string;
+
+ constructor(
+ private globalState: GlobalTestState,
+ private opts: WalletServiceOptions,
+ ) {
+ if (this.opts.overrideDbPath) {
+ this.internalDbPath = this.opts.overrideDbPath;
+ } else {
+ if (this.opts.useInMemoryDb) {
+ this.internalDbPath = ":memory:";
+ } else {
+ this.internalDbPath = path.join(
+ this.globalState.testDir,
+ `walletdb-${this.opts.name}.sqlite3`,
+ );
+ }
+ }
+ }
+
+ get socketPath() {
+ const unixPath = path.join(
+ this.globalState.testDir,
+ `${this.opts.name}.sock`,
+ );
+ return unixPath;
+ }
+
+ get dbPath() {
+ return this.internalDbPath;
+ }
+
+ async stop(): Promise<void> {
+ if (this.walletProc) {
+ this.walletProc.proc.kill("SIGTERM");
+ await this.walletProc.wait();
+ }
+ }
+
+ async start(): Promise<void> {
+ const unixPath = this.socketPath;
+ this.walletProc = this.globalState.spawnService(
+ "taler-wallet-cli",
+ [
+ "--wallet-db",
+ this.dbPath,
+ "-LTRACE", // FIXME: Make this configurable?
+ "--no-throttle", // FIXME: Optionally do throttling for some tests?
+ "advanced",
+ "serve",
+ "--unix-path",
+ unixPath,
+ "--no-init",
+ ],
+ `wallet-${this.opts.name}`,
+ );
+ logger.info(
+ `hint: connect to wallet using taler-wallet-cli --wallet-connection=${unixPath}`,
+ );
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ let nextDelay = backoffStart();
+ while (1) {
+ try {
+ await tryUnixConnect(this.socketPath);
+ } catch (e) {
+ logger.info(`wallet connection attempt failed: ${e}`);
+ logger.info(`waiting on wallet for ${nextDelay}ms`);
+ await delayMs(nextDelay);
+ nextDelay = backoffIncrement(nextDelay);
+ continue;
+ }
+ logger.info("connection to wallet-core succeeded");
+ break;
+ }
+ }
+}
+
+export interface WalletClientArgs {
+ name?: string;
+ unixPath: string;
+ onNotification?(n: WalletNotification): void;
+}
+
+export type CancelFn = () => void;
+export type NotificationHandler = (n: WalletNotification) => void;
+
+/**
+ * Convenience wrapper around a (remote) wallet handle.
+ */
+export class WalletClient {
+ remoteWallet: RemoteWallet | undefined = undefined;
+ private waiter: WalletNotificationWaiter = makeNotificationWaiter();
+ notificationHandlers: NotificationHandler[] = [];
+
+ addNotificationListener(f: NotificationHandler): CancelFn {
+ this.notificationHandlers.push(f);
+ return () => {
+ const idx = this.notificationHandlers.indexOf(f);
+ if (idx >= 0) {
+ this.notificationHandlers.splice(idx, 1);
+ }
+ };
+ }
+
+ async call<Op extends keyof WalletOperations>(
+ operation: Op,
+ payload: WalletCoreRequestType<Op>,
+ ): Promise<WalletCoreResponseType<Op>> {
+ if (!this.remoteWallet) {
+ throw Error("wallet not connected");
+ }
+ const client = getClientFromRemoteWallet(this.remoteWallet);
+ return client.call(operation, payload);
+ }
+
+ constructor(private args: WalletClientArgs) {}
+
+ async connect(): Promise<void> {
+ const waiter = this.waiter;
+ const walletClient = this;
+ const w = await createRemoteWallet({
+ name: this.args.name,
+ socketFilename: this.args.unixPath,
+ notificationHandler(n) {
+ if (walletClient.args.onNotification) {
+ walletClient.args.onNotification(n);
+ }
+ waiter.notify(n);
+ for (const h of walletClient.notificationHandlers) {
+ h(n);
+ }
+ },
+ });
+ this.remoteWallet = w;
+
+ this.waiter.waitForNotificationCond;
+ }
+
+ get client() {
+ if (!this.remoteWallet) {
+ throw Error("wallet not connected");
+ }
+ return getClientFromRemoteWallet(this.remoteWallet);
+ }
+
+ waitForNotificationCond<T>(
+ cond: (n: WalletNotification) => T | undefined | false,
+ ): Promise<T> {
+ return this.waiter.waitForNotificationCond(cond);
+ }
+}
+
+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 logName = `wallet-${self.name}`;
+ const command = `taler-wallet-cli ${
+ self.timetravelArg ?? ""
+ } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${
+ self.dbfile
+ }' api '${op}' ${shellWrap(JSON.stringify(payload))}`;
+ const resp = await sh(self.globalTestState, logName, command);
+ logger.info("--- wallet core response ---");
+ logger.info(resp);
+ logger.info("--- end of response ---");
+ let ar: CoreApiResponse;
+ try {
+ ar = JSON.parse(resp);
+ } catch (e) {
+ throw new CommandError(
+ "wallet CLI did not return a proper JSON response",
+ logName,
+ command,
+ [],
+ {},
+ null,
+ );
+ }
+ 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: {} = {}): 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",
+ ],
+ );
+ }
+
+ 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 generateRandomTestIban(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 {
+ return "x-taler-bank";
+}
+
+/**
+ * Generate a payto address, whose authority depends
+ * on whether the banking is served by euFin or Pybank.
+ */
+export function generateRandomPayto(label: string): string {
+ return `payto://x-taler-bank/localhost/${label}?receiver-name=${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..4e3ce66b9
--- /dev/null
+++ b/packages/taler-harness/src/harness/helpers.ts
@@ -0,0 +1,954 @@
+/*
+ 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,
+ Duration,
+ Logger,
+ MerchantApiClient,
+ NotificationType,
+ PartialWalletRunConfig,
+ PreparePayResultType,
+ TalerCorebankApiClient,
+ TalerMerchantApi,
+ TransactionMajorState,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "./denomStructures.js";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectedMerchantService,
+} from "./faultInjection.js";
+import {
+ BankService,
+ DbInfo,
+ ExchangeService,
+ ExchangeServiceInterface,
+ FakebankService,
+ GlobalTestState,
+ HarnessExchangeBankAccount,
+ LibeufinBankService,
+ MerchantService,
+ MerchantServiceInterface,
+ WalletCli,
+ WalletClient,
+ WalletService,
+ WithAuthorization,
+ generateRandomPayto,
+ setupDb,
+ setupSharedDb,
+ useLibeufinBank,
+} from "./harness.js";
+
+import * as fs from "fs";
+
+const logger = new Logger("helpers.ts");
+
+/**
+ * @deprecated
+ */
+export interface SimpleTestEnvironment {
+ commonDb: DbInfo;
+ bank: BankService;
+ exchange: ExchangeService;
+ exchangeBankAccount: HarnessExchangeBankAccount;
+ merchant: MerchantService;
+ wallet: WalletCli;
+}
+
+/**
+ * Improved version of the simple test environment,
+ * with the daemonized wallet.
+ */
+export interface SimpleTestEnvironmentNg {
+ commonDb: DbInfo;
+ bank: FakebankService;
+ exchange: ExchangeService;
+ exchangeBankAccount: HarnessExchangeBankAccount;
+ merchant: MerchantService;
+ walletClient: WalletClient;
+ walletService: WalletService;
+}
+
+/**
+ * Improved version of the simple test environment,
+ * passing bankClient instead of bank service.
+ */
+export interface SimpleTestEnvironmentNg3 {
+ commonDb: DbInfo;
+ bankClient: TalerCorebankApiClient;
+ exchange: ExchangeService;
+ exchangeBankAccount: HarnessExchangeBankAccount;
+ merchant: MerchantService;
+ walletClient: WalletClient;
+ walletService: WalletService;
+}
+
+export interface EnvOptions {
+ /**
+ * If provided, enable age restrictions with the specified age mask string.
+ */
+ ageMaskSpec?: string;
+
+ mixedAgeRestriction?: boolean;
+
+ additionalExchangeConfig?(e: ExchangeService): void;
+ additionalMerchantConfig?(m: MerchantService): void;
+ additionalBankConfig?(b: BankService): void;
+}
+
+export function getSharedTestDir(): string {
+ return `/tmp/taler-harness@${process.env.USER}`;
+}
+
+export async function useSharedTestkudosEnvironment(t: GlobalTestState) {
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+
+ const sharedDir = getSharedTestDir();
+
+ fs.mkdirSync(sharedDir, { recursive: true });
+
+ const db = await setupSharedDb(t);
+
+ let bank: FakebankService;
+
+ const prevSetupDone = fs.existsSync(sharedDir + "/setup-done");
+
+ logger.info(`previous setup done: ${prevSetupDone}`);
+
+ // Wallet has longer startup-time and no dependencies,
+ // so we start it rather early.
+ const walletStartProm = createWalletDaemonWithClient(t, { name: "wallet" });
+
+ if (fs.existsSync(sharedDir + "/bank.conf")) {
+ logger.info("reusing existing bank");
+ bank = FakebankService.fromExistingConfig(t, {
+ overridePath: sharedDir,
+ });
+ } else {
+ logger.info("creating new bank config");
+ bank = await FakebankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ overrideTestDir: sharedDir,
+ });
+ }
+
+ logger.info("setting up exchange");
+
+ const exchangeName = "testexchange-1";
+ const exchangeConfigFilename = sharedDir + `/exchange-${exchangeName}.conf`;
+
+ logger.info(`exchange config filename: ${exchangeConfigFilename}`);
+
+ let exchange: ExchangeService;
+
+ if (fs.existsSync(exchangeConfigFilename)) {
+ logger.info("reusing existing exchange config");
+ exchange = ExchangeService.fromExistingConfig(t, exchangeName, {
+ overridePath: sharedDir,
+ });
+ } else {
+ logger.info("creating new exchange config");
+ exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ overrideTestDir: sharedDir,
+ });
+ }
+
+ logger.info("setting up merchant");
+
+ let merchant: MerchantService;
+ const merchantName = "testmerchant-1";
+ const merchantConfigFilename = sharedDir + `/merchant-${merchantName}}`;
+
+ if (fs.existsSync(merchantConfigFilename)) {
+ merchant = MerchantService.fromExistingConfig(t, merchantName, {
+ overridePath: sharedDir,
+ });
+ } else {
+ merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ overrideTestDir: sharedDir,
+ });
+ }
+
+ logger.info("creating bank account for exchange");
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+
+ logger.info("creating exchange bank account");
+ await exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ exchange.addCoinConfigList(coinConfig);
+
+ merchant.addExchange(exchange);
+
+ logger.info("basic setup done, starting services");
+
+ if (!prevSetupDone) {
+ // Must be done sequentially, due to a concurrency
+ // issue in the *-dbinit tools.
+ await exchange.dbinit();
+ await merchant.dbinit();
+ }
+
+ const bankStart = async () => {
+ await bank.start();
+ await bank.pingUntilAvailable();
+ };
+
+ const exchangeStart = async () => {
+ await exchange.start({
+ skipDbinit: true,
+ skipKeyup: prevSetupDone,
+ });
+ await exchange.pingUntilAvailable();
+ };
+
+ const merchStart = async () => {
+ await merchant.start({
+ skipDbinit: true,
+ });
+ await merchant.pingUntilAvailable();
+
+ if (!prevSetupDone) {
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+ }
+ };
+
+ await bankStart();
+
+ const res = await Promise.all([
+ exchangeStart(),
+ merchStart(),
+ undefined,
+ walletStartProm,
+ ]);
+
+ const walletClient = res[3].walletClient;
+ const walletService = res[3].walletService;
+
+ fs.writeFileSync(sharedDir + "/setup-done", "OK");
+
+ logger.info("setup done!");
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ walletService,
+ bank,
+ exchangeBankAccount,
+ };
+}
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ *
+ * V2 uses a daemonized wallet instead of the CLI wallet.
+ */
+export async function createSimpleTestkudosEnvironmentV2(
+ t: GlobalTestState,
+ coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ opts: EnvOptions = {},
+): Promise<SimpleTestEnvironmentNg> {
+ 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",
+ );
+ await exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ if (opts.additionalBankConfig) {
+ opts.additionalBankConfig(bank);
+ }
+ 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);
+ }
+
+ if (opts.additionalExchangeConfig) {
+ opts.additionalExchangeConfig(exchange);
+ }
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ if (opts.additionalMerchantConfig) {
+ opts.additionalMerchantConfig(merchant);
+ }
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ { name: "wallet", persistent: true },
+ );
+
+ console.log("setup done!");
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ walletService,
+ bank,
+ exchangeBankAccount,
+ };
+}
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ *
+ * V3 uses the unified Corebank API and allows to choose between
+ * Fakebank and Libeufin-bank.
+ */
+export async function createSimpleTestkudosEnvironmentV3(
+ t: GlobalTestState,
+ coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ opts: EnvOptions = {},
+): Promise<SimpleTestEnvironmentNg3> {
+ const db = await setupDb(t);
+
+ const bc = {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ };
+
+ const bank: BankService = useLibeufinBank
+ ? await LibeufinBankService.create(t, bc)
+ : await FakebankService.create(t, bc);
+
+ 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 receiverName = "Exchange";
+ const exchangeBankUsername = "exchange";
+ const exchangeBankPassword = "mypw";
+ const exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+ const wireGatewayApiBaseUrl = new URL(
+ "accounts/exchange/taler-wire-gateway/",
+ bank.corebankApiBaseUrl,
+ ).href;
+
+ const exchangeBankAccount = {
+ wireGatewayApiBaseUrl,
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ accountPaytoUri: exchangePaytoUri,
+ };
+
+ await exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ if (opts.additionalBankConfig) {
+ opts.additionalBankConfig(bank);
+ }
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ 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);
+ }
+
+ if (opts.additionalExchangeConfig) {
+ opts.additionalExchangeConfig(exchange);
+ }
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ if (opts.additionalMerchantConfig) {
+ opts.additionalMerchantConfig(merchant);
+ }
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ { name: "wallet", persistent: true },
+ );
+
+ console.log("setup done!");
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ walletService,
+ bankClient,
+ exchangeBankAccount,
+ };
+}
+
+export interface CreateWalletArgs {
+ handleNotification?(wn: WalletNotification): void;
+ name: string;
+ persistent?: boolean;
+ overrideDbPath?: string;
+ config?: PartialWalletRunConfig;
+}
+
+export async function createWalletDaemonWithClient(
+ t: GlobalTestState,
+ args: CreateWalletArgs,
+): Promise<{ walletClient: WalletClient; walletService: WalletService }> {
+ const walletService = new WalletService(t, {
+ name: args.name,
+ useInMemoryDb: !args.persistent,
+ overrideDbPath: args.overrideDbPath,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const observabilityEventFile = t.testDir + `/wallet-${args.name}-notifs.log`;
+
+ const onNotif = (notif: WalletNotification) => {
+ if (observabilityEventFile) {
+ fs.appendFileSync(
+ observabilityEventFile,
+ new Date().toISOString() + " " + JSON.stringify(notif) + "\n",
+ );
+ }
+ if (args.handleNotification) {
+ args.handleNotification(notif);
+ }
+ };
+
+ const walletClient = new WalletClient({
+ name: args.name,
+ unixPath: walletService.socketPath,
+ onNotification: onNotif,
+ });
+ await walletClient.connect();
+ const defaultRunConfig = {
+ testing: {
+ skipDefaults: true,
+ emitObservabilityEvents: !!process.env["TALER_TEST_OBSERVABILITY"],
+ },
+ } satisfies PartialWalletRunConfig;
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: args.config ?? defaultRunConfig,
+ });
+
+ return { walletClient, walletService };
+}
+
+export interface FaultyMerchantTestEnvironment {
+ commonDb: DbInfo;
+ bank: FakebankService;
+ exchange: ExchangeService;
+ faultyExchange: FaultInjectedExchangeService;
+ exchangeBankAccount: HarnessExchangeBankAccount;
+ merchant: MerchantService;
+ faultyMerchant: FaultInjectedMerchantService;
+ walletClient: WalletClient;
+}
+
+export interface FaultyMerchantTestEnvironmentNg {
+ commonDb: DbInfo;
+ bankClient: TalerCorebankApiClient;
+ exchange: ExchangeService;
+ faultyExchange: FaultInjectedExchangeService;
+ merchant: MerchantService;
+ faultyMerchant: FaultInjectedMerchantService;
+ walletClient: WalletClient;
+}
+
+/**
+ * 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 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 faultyMerchant = new FaultInjectedMerchantService(t, merchant, 9083);
+ const faultyExchange = new FaultInjectedExchangeService(t, exchange, 9081);
+
+ // Base URL must contain port that the proxy is listening on.
+ await exchange.modifyConfig(async (config) => {
+ config.setString("exchange", "base_url", "http://localhost: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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
+ });
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ bank,
+ exchangeBankAccount,
+ faultyMerchant,
+ faultyExchange,
+ };
+}
+
+export interface WithdrawViaBankResult {
+ withdrawalFinishedCond: Promise<true>;
+}
+
+/**
+ * Withdraw via a bank with the testing API enabled.
+ * Uses the new notification-based mechanism to wait for the
+ * operation to finish.
+ */
+export async function withdrawViaBankV2(
+ t: GlobalTestState,
+ p: {
+ walletClient: WalletClient;
+ bank: BankService;
+ exchange: ExchangeServiceInterface;
+ amount: AmountString | string;
+ restrictAge?: number;
+ },
+): Promise<WithdrawViaBankResult> {
+ const { walletClient: wallet, bank, exchange, amount } = p;
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl);
+
+ const user = await bankClient.createRandomBankUser();
+ const wop = await bankClient.createWithdrawalOperation(user.username, amount);
+
+ // Hand it to the wallet
+
+ await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ restrictAge: p.restrictAge,
+ });
+
+ // Withdraw (AKA select)
+
+ const acceptRes = await wallet.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ restrictAge: p.restrictAge,
+ },
+ );
+
+ const withdrawalFinishedCond = wallet.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Done &&
+ x.transactionId === acceptRes.transactionId,
+ );
+
+ // Confirm it
+
+ await bankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ return {
+ withdrawalFinishedCond,
+ };
+}
+
+/**
+ * Withdraw via a bank with the testing API enabled.
+ * Uses the new Corebank API.
+ */
+export async function withdrawViaBankV3(
+ t: GlobalTestState,
+ p: {
+ walletClient: WalletClient;
+ bankClient: TalerCorebankApiClient;
+ exchange: ExchangeServiceInterface;
+ amount: AmountString | string;
+ restrictAge?: number;
+ },
+): Promise<WithdrawViaBankResult> {
+ const { walletClient: wallet, bankClient, exchange, amount } = p;
+
+ const user = await bankClient.createRandomBankUser();
+ const bankClient2 = new TalerCorebankApiClient(bankClient.baseUrl);
+ bankClient2.setAuth({
+ username: user.username,
+ password: user.password,
+ });
+
+ const wop = await bankClient2.createWithdrawalOperation(
+ user.username,
+ amount,
+ );
+
+ // Hand it to the wallet
+
+ await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ restrictAge: p.restrictAge,
+ });
+
+ // Withdraw (AKA select)
+
+ const acceptRes = await wallet.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ restrictAge: p.restrictAge,
+ },
+ );
+
+ const withdrawalFinishedCond = wallet.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Done &&
+ x.transactionId === acceptRes.transactionId,
+ );
+
+ // Confirm it
+
+ await bankClient2.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ return {
+ withdrawalFinishedCond,
+ };
+}
+
+export async function applyTimeTravelV2(
+ timetravelOffsetMs: number,
+ s: {
+ exchange?: ExchangeService;
+ merchant?: MerchantService;
+ walletClient?: WalletClient;
+ },
+): Promise<void> {
+ if (s.exchange) {
+ await s.exchange.stop();
+ s.exchange.setTimetravel(timetravelOffsetMs);
+ await s.exchange.start();
+ await s.exchange.pingUntilAvailable();
+ }
+
+ if (s.merchant) {
+ await s.merchant.stop();
+ s.merchant.setTimetravel(timetravelOffsetMs);
+ await s.merchant.start();
+ await s.merchant.pingUntilAvailable();
+ }
+
+ if (s.walletClient) {
+ await s.walletClient.call(WalletApiOperation.TestingSetTimetravel, {
+ offsetMs: timetravelOffsetMs,
+ });
+ }
+}
+
+/**
+ * Make a simple payment and check that it succeeded.
+ */
+export async function makeTestPaymentV2(
+ t: GlobalTestState,
+ args: {
+ merchant: MerchantServiceInterface;
+ walletClient: WalletClient;
+ order: TalerMerchantApi.Order;
+ instance?: string;
+ },
+ auth: WithAuthorization = {},
+): Promise<void> {
+ // Set up order.
+
+ const { walletClient, merchant, instance } = args;
+
+ const merchantClient = new MerchantApiClient(
+ merchant.makeInstanceBaseUrl(instance),
+ );
+
+ const orderResp = await merchantClient.createOrder({
+ order: args.order,
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ instance,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+}
diff --git a/packages/taler-harness/src/harness/sync.ts b/packages/taler-harness/src/harness/sync.ts
new file mode 100644
index 000000000..567a2e92d
--- /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.writeTo(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/import-meta-url.js b/packages/taler-harness/src/import-meta-url.js
new file mode 100644
index 000000000..c0e657160
--- /dev/null
+++ b/packages/taler-harness/src/import-meta-url.js
@@ -0,0 +1,2 @@
+// Helper to make 'import.meta.url' available in esbuild-bundled code as well.
+export const import_meta_url = require("url").pathToFileURL(__filename);
diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts
new file mode 100644
index 000000000..99b5502d8
--- /dev/null
+++ b/packages/taler-harness/src/index.ts
@@ -0,0 +1,1322 @@
+/*
+ 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 {
+ AccessToken,
+ AmountString,
+ Amounts,
+ BalancesResponse,
+ Configuration,
+ Duration,
+ HttpStatusCode,
+ Logger,
+ PaytoString,
+ TalerAuthenticationHttpClient,
+ TalerBankConversionHttpClient,
+ TalerCoreBankHttpClient,
+ TalerMerchantInstanceHttpClient,
+ TalerMerchantManagementHttpClient,
+ TransactionsResponse,
+ createRFC8959AccessTokenEncoded,
+ createRFC8959AccessTokenPlain,
+ decodeCrock,
+ encodeCrock,
+ generateIban,
+ j2s,
+ randomBytes,
+ rsaBlind,
+ setGlobalLogLevelFromString,
+ stringifyPayTemplateUri,
+} from "@gnu-taler/taler-util";
+import { clk } from "@gnu-taler/taler-util/clk";
+import {
+ HttpResponse,
+ createPlatformHttpLib,
+} from "@gnu-taler/taler-util/http";
+import {
+ CryptoDispatcher,
+ SynchronousCryptoWorkerFactoryPlain,
+ WalletApiOperation,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ downloadExchangeInfo,
+ topupReserveWithBank,
+} from "@gnu-taler/taler-wallet-core/dbless";
+import { deepStrictEqual } from "assert";
+import fs from "fs";
+import os from "os";
+import path from "path";
+import { runBench1 } from "./bench1.js";
+import { runBench2 } from "./bench2.js";
+import { runBench3 } from "./bench3.js";
+import { runEnvFull } from "./env-full.js";
+import { runEnv1 } from "./env1.js";
+import {
+ GlobalTestState,
+ WalletClient,
+ delayMs,
+ runTestWithState,
+} from "./harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+} from "./harness/helpers.js";
+import { getTestInfo, runTests } from "./integrationtests/testrunner.js";
+import { lintExchangeDeployment } from "./lint.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;
+declare const __GIT_HASH__: string;
+function printVersion(): void {
+ console.log(`${__VERSION__} ${__GIT_HASH__}`);
+ 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("decode", "decode", {
+ help: "Decode base32-crockford.",
+ })
+ .action((args) => {
+ const enc = fs.readFileSync(0, "utf8");
+ console.log(decodeCrock(enc.trim()));
+ });
+
+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);
+ });
+
+async function doDbChecks(
+ t: GlobalTestState,
+ walletClient: WalletClient,
+ indir: string,
+): Promise<void> {
+ // Check that balance didn't break
+ const balPath = `${indir}/wallet-balances.json`;
+ const expectedBal: BalancesResponse = JSON.parse(
+ fs.readFileSync(balPath, { encoding: "utf8" }),
+ ) as BalancesResponse;
+ const actualBal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ t.assertDeepEqual(actualBal.balances.length, expectedBal.balances.length);
+
+ // Check that transactions didn't break
+ const txnPath = `${indir}/wallet-transactions.json`;
+ const expectedTxn: TransactionsResponse = JSON.parse(
+ fs.readFileSync(txnPath, { encoding: "utf8" }),
+ ) as TransactionsResponse;
+ const actualTxn = await walletClient.call(
+ WalletApiOperation.GetTransactions,
+ { includeRefreshes: true },
+ );
+ t.assertDeepEqual(
+ actualTxn.transactions.length,
+ expectedTxn.transactions.length,
+ );
+}
+
+advancedCli
+ .subcommand("walletDbcheck", "wallet-dbcheck", {
+ help: "Check a wallet database (used for migration testing).",
+ })
+ .requiredArgument("indir", clk.STRING)
+ .action(async (args) => {
+ const indir = args.walletDbcheck.indir;
+ if (!fs.existsSync(indir)) {
+ throw Error("directory to be checked does not exist");
+ }
+
+ const testRootDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-dbchk-"));
+ const t: GlobalTestState = new GlobalTestState({
+ testDir: testRootDir,
+ });
+ const origWalletDbPath = `${indir}/wallet-db.sqlite3`;
+ const testWalletDbPath = `${testRootDir}/wallet-testdb.sqlite3`;
+ fs.cpSync(origWalletDbPath, testWalletDbPath);
+ if (!fs.existsSync(origWalletDbPath)) {
+ throw new Error("wallet db to be checked does not exist");
+ }
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ { name: "wallet-loaded", overrideDbPath: testWalletDbPath },
+ );
+
+ await walletService.pingUntilAvailable();
+
+ // Do DB checks with the DB we loaded.
+ await doDbChecks(t, walletClient, indir);
+
+ const {
+ walletClient: freshWalletClient,
+ walletService: freshWalletService,
+ } = await createWalletDaemonWithClient(t, {
+ name: "wallet-fresh",
+ persistent: false,
+ });
+
+ await freshWalletService.pingUntilAvailable();
+
+ // Check that we can still import the backup JSON.
+
+ const backupPath = `${indir}/wallet-backup.json`;
+ const backupData = JSON.parse(
+ fs.readFileSync(backupPath, { encoding: "utf8" }),
+ );
+ await freshWalletClient.call(WalletApiOperation.ImportDb, {
+ dump: backupData,
+ });
+
+ // Repeat same checks with wallet that we restored from backup
+ // instead of from the DB file.
+ await doDbChecks(t, freshWalletClient, indir);
+
+ await t.shutdown();
+ });
+
+advancedCli
+ .subcommand("walletDbgen", "wallet-dbgen", {
+ help: "Generate a wallet test database (to be used for migration testing).",
+ })
+ .requiredArgument("outdir", clk.STRING)
+ .action(async (args) => {
+ const outdir = args.walletDbgen.outdir;
+ if (fs.existsSync(outdir)) {
+ throw new Error("outdir already exists, please delete first");
+ }
+ fs.mkdirSync(outdir, {
+ recursive: true,
+ });
+
+ const testRootDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-dbgen-"));
+ console.log(`generating data in ${testRootDir}`);
+ const t = new GlobalTestState({
+ testDir: testRootDir,
+ });
+ const { walletClient, walletService, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+ await walletClient.call(WalletApiOperation.RunIntegrationTestV2, {
+ amountToSpend: "TESTKUDOS:5" as AmountString,
+ amountToWithdraw: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ const transactionsJson = await walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {
+ includeRefreshes: true,
+ },
+ );
+
+ const balancesJson = await walletClient.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+
+ const backupJson = await walletClient.call(WalletApiOperation.ExportDb, {});
+
+ const versionJson = await walletClient.call(
+ WalletApiOperation.GetVersion,
+ {},
+ );
+
+ await walletService.stop();
+
+ await t.shutdown();
+
+ console.log(`generated data in ${testRootDir}`);
+
+ fs.copyFileSync(walletService.dbPath, `${outdir}/wallet-db.sqlite3`);
+ fs.writeFileSync(
+ `${outdir}/wallet-transactions.json`,
+ j2s(transactionsJson),
+ );
+ fs.writeFileSync(`${outdir}/wallet-balances.json`, j2s(balancesJson));
+ fs.writeFileSync(`${outdir}/wallet-backup.json`, j2s(backupJson));
+ fs.writeFileSync(`${outdir}/wallet-version.json`, j2s(versionJson));
+ fs.writeFileSync(
+ `${outdir}/meta.json`,
+ j2s({
+ timestamp: new Date(),
+ }),
+ );
+ });
+
+const configCli = testingCli
+ .subcommand("configArgs", "config", {
+ help: "Subcommands for handling the Taler configuration.",
+ })
+ .maybeOption("configEntryFile", ["-c", "--config"], clk.STRING, {
+ help: "Configuration file to use.",
+ })
+ .maybeOption("project", ["--project"], clk.STRING, {
+ help: `Selection of the project to inspect/change the config (default: taler).`,
+ });
+
+configCli
+ .subcommand("show", "show", {
+ help: "Show the current configuration.",
+ })
+ .action(async (args) => {
+ const config = Configuration.load(
+ args.configArgs.configEntryFile,
+ args.configArgs.project,
+ );
+ const cfgStr = config.stringify({
+ diagnostics: true,
+ });
+ console.log(cfgStr);
+ });
+
+configCli
+ .subcommand("get", "get", {
+ help: "Get a configuration option.",
+ })
+ .requiredArgument("section", clk.STRING)
+ .requiredArgument("option", clk.STRING)
+ .flag("file", ["-f"], {
+ help: "Treat the value as a filename, expanding placeholders.",
+ })
+ .action(async (args) => {
+ const config = Configuration.load(
+ args.configArgs.configEntryFile,
+ args.configArgs.project,
+ );
+ let res;
+ if (args.get.file) {
+ res = config.getPath(args.get.section, args.get.option);
+ } else {
+ res = config.getString(args.get.section, args.get.option);
+ }
+ if (res.isDefined()) {
+ console.log(res.required());
+ } else {
+ console.warn("not found");
+ process.exit(1);
+ }
+ });
+
+configCli
+ .subcommand("set", "set", {
+ help: "Set a configuration option.",
+ })
+ .requiredArgument("section", clk.STRING)
+ .requiredArgument("option", clk.STRING)
+ .requiredArgument("value", clk.STRING)
+ .flag("dry", ["--dry"], {
+ help: "Do not write the changed config to disk, only write it to stdout.",
+ })
+ .action(async (args) => {
+ const config = Configuration.load(
+ args.configArgs.configEntryFile,
+ args.configArgs.project,
+ );
+ config.setString(args.set.section, args.set.option, args.set.value);
+ if (args.set.dry) {
+ console.log(
+ config.stringify({
+ excludeDefaults: true,
+ }),
+ );
+ } else {
+ config.write({
+ excludeDefaults: true,
+ });
+ }
+ });
+
+const deploymentCli = testingCli.subcommand("deploymentArgs", "deployment", {
+ help: "Subcommands for handling GNU Taler deployments.",
+});
+
+deploymentCli
+ .subcommand("testTalerdotnetDemo", "test-demodottalerdotnet")
+ .action(async (args) => {
+ const http = createPlatformHttpLib();
+ const cryptiDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptiDisp.cryptoApi;
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+ const exchangeBaseUrl = "https://exchange.demo.taler.net/";
+ const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http);
+ await topupReserveWithBank({
+ amount: "KUDOS:10" as AmountString,
+ corebankApiBaseUrl: "https://bank.demo.taler.net/",
+ exchangeInfo,
+ http,
+ reservePub: reserveKeyPair.pub,
+ });
+ let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl);
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+ console.log("requesting", reserveUrl.href);
+ const longpollReq = http.fetch(reserveUrl.href, {
+ method: "GET",
+ });
+ const reserveStatusResp = await longpollReq;
+ console.log("reserve status", reserveStatusResp.status);
+ });
+
+deploymentCli
+ .subcommand("testDemoTestdotdalerdotnet", "test-testdottalerdotnet")
+ .action(async (args) => {
+ const http = createPlatformHttpLib();
+ const cryptiDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptiDisp.cryptoApi;
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+ const exchangeBaseUrl = "https://exchange.test.taler.net/";
+ const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http);
+ await topupReserveWithBank({
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: "https://bank.test.taler.net/",
+ exchangeInfo,
+ http,
+ reservePub: reserveKeyPair.pub,
+ });
+ let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl);
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+ console.log("requesting", reserveUrl.href);
+ const longpollReq = http.fetch(reserveUrl.href, {
+ method: "GET",
+ });
+ const reserveStatusResp = await longpollReq;
+ console.log("reserve status", reserveStatusResp.status);
+ });
+
+deploymentCli
+ .subcommand("testLocalhostDemo", "test-demo-localhost")
+ .action(async (args) => {
+ // Run checks against the "env-full" demo deployment on localhost
+ const http = createPlatformHttpLib();
+ const cryptiDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptiDisp.cryptoApi;
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+ const exchangeBaseUrl = "http://localhost:8081/";
+ const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http);
+ await topupReserveWithBank({
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: "http://localhost:8082/taler-bank-access/",
+ exchangeInfo,
+ http,
+ reservePub: reserveKeyPair.pub,
+ });
+ let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl);
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+ console.log("requesting", reserveUrl.href);
+ const longpollReq = http.fetch(reserveUrl.href, {
+ method: "GET",
+ });
+ const reserveStatusResp = await longpollReq;
+ console.log("reserve status", reserveStatusResp.status);
+ });
+
+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("waitService", "wait-taler-service", {
+ help: "Wait for the config endpoint of a Taler-style service to be available",
+ })
+ .requiredArgument("serviceName", clk.STRING)
+ .requiredArgument("serviceConfigUrl", clk.STRING)
+ .action(async (args) => {
+ const serviceName = args.waitService.serviceName;
+ const serviceUrl = args.waitService.serviceConfigUrl;
+ console.log(
+ `Waiting for service ${serviceName} to be ready at ${serviceUrl}`,
+ );
+ const httpLib = createPlatformHttpLib();
+ while (1) {
+ console.log(`Fetching ${serviceUrl}`);
+ let resp: HttpResponse;
+ try {
+ resp = await httpLib.fetch(serviceUrl);
+ } catch (e) {
+ console.log(
+ `Got network error for service ${serviceName} at ${serviceUrl}`,
+ );
+ await delayMs(1000);
+ continue;
+ }
+ if (resp.status != 200) {
+ console.log(
+ `Got unexpected status ${resp.status} for service at ${serviceUrl}`,
+ );
+ await delayMs(1000);
+ continue;
+ }
+ let respJson: any;
+ try {
+ respJson = await resp.json();
+ } catch (e) {
+ console.log(
+ `Got json error for service ${serviceName} at ${serviceUrl}`,
+ );
+ await delayMs(1000);
+ continue;
+ }
+ const recServiceName = respJson.name;
+ console.log(`Got name ${recServiceName}`);
+ if (recServiceName != serviceName) {
+ console.log(`A different service is still running at ${serviceUrl}`);
+ await delayMs(1000);
+ continue;
+ }
+ console.log(`service ${serviceName} at ${serviceUrl} is now available`);
+ return;
+ }
+ });
+
+deploymentCli
+ .subcommand("waitEndpoint", "wait-endpoint", {
+ help: "Wait for an endpoint to return an HTTP 200 Ok status with JSON body",
+ })
+ .requiredArgument("serviceEndpoint", clk.STRING)
+ .action(async (args) => {
+ const serviceUrl = args.waitEndpoint.serviceEndpoint;
+ console.log(`Waiting for endpoint ${serviceUrl} to be ready`);
+ const httpLib = createPlatformHttpLib();
+ while (1) {
+ console.log(`Fetching ${serviceUrl}`);
+ let resp: HttpResponse;
+ try {
+ resp = await httpLib.fetch(serviceUrl);
+ } catch (e) {
+ console.log(`Got network error for service at ${serviceUrl}`);
+ await delayMs(1000);
+ continue;
+ }
+ if (resp.status != 200) {
+ console.log(
+ `Got unexpected status ${resp.status} for service at ${serviceUrl}`,
+ );
+ await delayMs(1000);
+ continue;
+ }
+ let respJson: any;
+ try {
+ respJson = await resp.json();
+ } catch (e) {
+ console.log(`Got json error for service at ${serviceUrl}`);
+ await delayMs(1000);
+ continue;
+ }
+ return;
+ }
+ });
+
+deploymentCli
+ .subcommand("genIban", "gen-iban", {
+ help: "Generate a random IBAN.",
+ })
+ .requiredArgument("countryCode", clk.STRING)
+ .requiredArgument("length", clk.INT)
+ .action(async (args) => {
+ console.log(generateIban(args.genIban.countryCode, args.genIban.length));
+ });
+
+deploymentCli
+ .subcommand("provisionBankMerchant", "provision-bank-and-merchant", {
+ help: "Provision a bank account, merchant instance and link them together.",
+ })
+ .requiredArgument("merchantApiBaseUrl", clk.STRING, {
+ help: "URL location of the merchant backend",
+ })
+ .requiredArgument("corebankApiBaseUrl", clk.STRING, {
+ help: "URL location of the libeufin bank backend",
+ })
+ .requiredOption(
+ "merchantToken",
+ ["--merchant-management-token"],
+ clk.STRING,
+ {
+ help: "access token of the default instance in the merchant backend",
+ },
+ )
+ .maybeOption("bankToken", ["--bank-admin-token"], clk.STRING, {
+ help: "libeufin bank admin's token if the account creation is restricted",
+ })
+ .maybeOption("bankPassword", ["--bank-admin-password"], clk.STRING, {
+ help: "libeufin bank admin's password if the account creation is restricted, it will override --bank-admin-token",
+ })
+ .requiredOption("name", ["--legal-name"], clk.STRING, {
+ help: "legal name of the merchant",
+ })
+ .maybeOption("email", ["--email"], clk.STRING, {
+ help: "email contact of the merchant",
+ })
+ .maybeOption("phone", ["--phone"], clk.STRING, {
+ help: "phone contact of the merchant",
+ })
+ .requiredOption("id", ["--id"], clk.STRING, {
+ help: "login id for the bank account and instance id of the merchant backend",
+ })
+ .flag("template", ["--create-template"], {
+ help: "use this flag to create a default template for the merchant with fixed summary",
+ })
+ .requiredOption("password", ["--password"], clk.STRING, {
+ help: "password of the accounts in libeufin bank and merchant backend",
+ })
+ .flag("randomPassword", ["--set-random-password"], {
+ help: "if everything worked ok, change the password of the accounts at the end",
+ })
+ .action(async (args) => {
+ const managementToken = createRFC8959AccessTokenPlain(
+ args.provisionBankMerchant.merchantToken,
+ );
+ const bankAdminPassword = args.provisionBankMerchant.bankPassword;
+ const bankAdminTokenArg = args.provisionBankMerchant.bankToken
+ ? createRFC8959AccessTokenPlain(args.provisionBankMerchant.bankToken)
+ : undefined;
+ const id = args.provisionBankMerchant.id;
+ const name = args.provisionBankMerchant.name;
+ const email = args.provisionBankMerchant.email;
+ const phone = args.provisionBankMerchant.phone;
+ const password = args.provisionBankMerchant.password;
+
+ const httpLib = createPlatformHttpLib({});
+ const merchantManager = new TalerMerchantManagementHttpClient(
+ args.provisionBankMerchant.merchantApiBaseUrl,
+ httpLib,
+ );
+ const bank = new TalerCoreBankHttpClient(
+ args.provisionBankMerchant.corebankApiBaseUrl,
+ httpLib,
+ );
+ const instanceURL = merchantManager.getSubInstanceAPI(id).href;
+ const merchantInstance = new TalerMerchantInstanceHttpClient(
+ instanceURL,
+ httpLib,
+ );
+ const conv = new TalerBankConversionHttpClient(
+ bank.getConversionInfoAPI().href,
+ httpLib,
+ );
+ const bankAuth = new TalerAuthenticationHttpClient(
+ bank.getAuthenticationAPI(id).href,
+ httpLib,
+ );
+
+ const bc = await bank.getConfig();
+ if (bc.type === "fail") {
+ logger.error(`couldn't get bank config. ${bc.detail.hint}`);
+ return;
+ }
+ if (!bank.isCompatible(bc.body.version)) {
+ logger.error(
+ `bank server version is not compatible: ${bc.body.version}, client version: ${bank.PROTOCOL_VERSION}`,
+ );
+ return;
+ }
+ const mc = await merchantManager.getConfig();
+ if (mc.type === "fail") {
+ logger.error(`couldn't get merchant config. ${mc.detail.hint}`);
+ return;
+ }
+ if (!merchantManager.isCompatible(mc.body.version)) {
+ logger.error(
+ `merchant server version is not compatible: ${mc.body.version}, client version: ${merchantManager.PROTOCOL_VERSION}`,
+ );
+ return;
+ }
+
+ let bankAdminToken: AccessToken | undefined;
+ if (bankAdminPassword) {
+ const adminAuth = new TalerAuthenticationHttpClient(
+ bank.getAuthenticationAPI("admin").href,
+ httpLib,
+ );
+
+ const resp = await adminAuth.createAccessTokenBasic(
+ "admin",
+ bankAdminPassword,
+ {
+ scope: "write",
+ duration: {
+ d_us: 1000 * 1000 * 10, //10 secs
+ },
+ refreshable: false,
+ },
+ );
+ if (resp.type === "fail") {
+ logger.error(`could not get bank admin token from password.`);
+ return;
+ }
+ bankAdminToken = resp.body.access_token;
+ } else {
+ bankAdminToken = bankAdminTokenArg;
+ }
+
+ /**
+ * create bank account
+ */
+ let accountPayto: PaytoString;
+ {
+ const resp = await bank.createAccount(bankAdminToken, {
+ name: name,
+ password: password,
+ username: id,
+ contact_data:
+ email || phone
+ ? {
+ email: email,
+ phone: phone,
+ }
+ : undefined,
+ });
+
+ if (resp.type === "fail") {
+ logger.error(
+ `unable to provision bank account, HTTP response status ${resp.case}`,
+ );
+ process.exit(2);
+ }
+ logger.info(`account ${id} successfully provisioned`);
+ accountPayto = resp.body.internal_payto_uri;
+ }
+
+ /**
+ * create merchant account
+ */
+ {
+ const resp = await merchantManager.createInstance(managementToken, {
+ address: {},
+ auth: {
+ method: "token",
+ token: createRFC8959AccessTokenPlain(password),
+ },
+ default_pay_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ default_wire_transfer_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ id: id,
+ jurisdiction: {},
+ name: name,
+ use_stefan: true,
+ });
+
+ if (resp.type === "ok") {
+ logger.info(`instance ${id} created successfully`);
+ } else if (resp.case === HttpStatusCode.Conflict) {
+ logger.info(`instance ${id} already exists`);
+ } else {
+ logger.error(
+ `unable to create instance ${id}, HTTP status ${resp.case}`,
+ );
+ process.exit(2);
+ }
+ }
+
+ let wireAccount: string;
+ /**
+ * link bank account and merchant
+ */
+ {
+ const resp = await merchantInstance.addBankAccount(
+ createRFC8959AccessTokenEncoded(password),
+ {
+ payto_uri: accountPayto,
+ credit_facade_url: bank.getRevenueAPI(id).href,
+ credit_facade_credentials: {
+ type: "basic",
+ username: id,
+ password: password,
+ },
+ },
+ );
+ if (resp.type === "fail") {
+ console.error(
+ `unable to configure bank account for instance ${id}, status ${resp.case}`,
+ );
+ console.error(j2s(resp.detail));
+ process.exit(2);
+ }
+ wireAccount = resp.body.h_wire;
+ }
+
+ logger.info(`successfully configured bank account for ${id}`);
+
+ let templateURI;
+ /**
+ * create template
+ */
+ if (args.provisionBankMerchant.template) {
+ let currency = bc.body.currency;
+ if (bc.body.allow_conversion) {
+ const cc = await conv.getConfig();
+ if (cc.type === "ok") {
+ currency = cc.body.fiat_currency;
+ } else {
+ console.error(`could not get fiat currency status ${cc.case}`);
+ console.error(j2s(cc.detail));
+ }
+ } else {
+ console.log(`conversion is disabled, using bank currency`);
+ }
+
+ {
+ const resp = await merchantInstance.addTemplate(
+ createRFC8959AccessTokenEncoded(password),
+ {
+ template_id: "default",
+ template_description: "First template",
+ template_contract: {
+ pay_duration: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ minimum_age: 0,
+ currency,
+ summary: "Pay me!",
+ },
+ editable_defaults: {
+ amount: currency,
+ },
+ },
+ );
+ if (resp.type === "fail") {
+ console.error(
+ `unable to create template for insntaince ${id}, status ${resp.case}`,
+ );
+ console.error(j2s(resp.detail));
+ process.exit(2);
+ }
+ }
+
+ logger.info(`template default successfully created`);
+ templateURI = stringifyPayTemplateUri({
+ merchantBaseUrl: instanceURL,
+ templateId: "default",
+ });
+ }
+
+ let finalPassword = password;
+ if (args.provisionBankMerchant.randomPassword) {
+ const prevPassword = password;
+ const randomPassword = encodeCrock(randomBytes(16));
+ logger.info("random password: ", randomPassword);
+ let token: AccessToken;
+ {
+ const resp = await bankAuth.createAccessTokenBasic(id, prevPassword, {
+ scope: "readwrite",
+ duration: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ refreshable: false,
+ });
+ if (resp.type === "fail") {
+ console.error(
+ `unable to login into bank accountfor user ${id}, status ${resp.case}`,
+ );
+ console.error(j2s(resp.detail));
+ process.exit(2);
+ }
+ token = resp.body.access_token;
+ }
+
+ {
+ const resp = await bank.updatePassword(
+ { username: id, token },
+ {
+ old_password: prevPassword,
+ new_password: randomPassword,
+ },
+ );
+ if (resp.type === "fail") {
+ console.error(
+ `unable to change bank password for user ${id}, status ${resp.case}`,
+ );
+ if (resp.case !== HttpStatusCode.Accepted) {
+ console.error(j2s(resp.detail));
+ } else {
+ console.error("2FA required");
+ }
+ process.exit(2);
+ }
+ }
+
+ {
+ const resp = await merchantInstance.updateCurrentInstanceAuthentication(
+ createRFC8959AccessTokenEncoded(prevPassword),
+ {
+ method: "token",
+ token: createRFC8959AccessTokenPlain(randomPassword),
+ },
+ );
+ if (resp.type === "fail") {
+ console.error(
+ `unable to change merchant password for instance ${id}, status ${resp.case}`,
+ );
+ console.error(j2s(resp.detail));
+ process.exit(2);
+ }
+ }
+
+ {
+ const resp = await merchantInstance.updateBankAccount(
+ createRFC8959AccessTokenEncoded(randomPassword),
+ wireAccount,
+ {
+ credit_facade_url: bank.getRevenueAPI(id).href,
+ credit_facade_credentials: {
+ type: "basic",
+ username: id,
+ password: randomPassword,
+ },
+ },
+ );
+ if (resp.type != "ok") {
+ console.error(
+ `unable to update bank account for instance ${id}, status ${resp.case}`,
+ );
+ console.error(j2s(resp.detail));
+ process.exit(2);
+ }
+ }
+ finalPassword = randomPassword;
+ }
+ logger.info(`successfully configured bank account for ${id}`);
+
+ /**
+ * show result
+ */
+ console.log(
+ JSON.stringify(
+ {
+ bankUser: id,
+ bankURL: args.provisionBankMerchant.corebankApiBaseUrl,
+ merchantURL: instanceURL,
+ templateURI,
+ password: finalPassword,
+ },
+ undefined,
+ 2,
+ ),
+ );
+ });
+
+deploymentCli
+ .subcommand("provisionMerchantInstance", "provision-merchant-instance", {
+ help: "Provision a merchant backend instance.",
+ })
+ .requiredArgument("merchantApiBaseUrl", clk.STRING)
+ .requiredOption("managementToken", ["--management-token"], clk.STRING)
+ .requiredOption("instanceToken", ["--instance-token"], clk.STRING)
+ .requiredOption("name", ["--name"], clk.STRING)
+ .requiredOption("id", ["--id"], clk.STRING)
+ .requiredOption("payto", ["--payto"], clk.STRING)
+ .maybeOption("bankURL", ["--bankURL"], clk.STRING)
+ .maybeOption("bankUser", ["--bankUser"], clk.STRING)
+ .maybeOption("bankPassword", ["--bankPassword"], clk.STRING)
+ .action(async (args) => {
+ const httpLib = createPlatformHttpLib({});
+ const baseUrl = args.provisionMerchantInstance.merchantApiBaseUrl;
+ const api = new TalerMerchantManagementHttpClient(baseUrl, httpLib);
+ const managementToken = createRFC8959AccessTokenEncoded(
+ args.provisionMerchantInstance.managementToken,
+ );
+ const instanceTokenEnc = createRFC8959AccessTokenPlain(
+ args.provisionMerchantInstance.instanceToken,
+ );
+ const instanceTokenPlain = createRFC8959AccessTokenPlain(
+ args.provisionMerchantInstance.instanceToken,
+ );
+ const instanceId = args.provisionMerchantInstance.id;
+ const instancceName = args.provisionMerchantInstance.name;
+ const bankURL = args.provisionMerchantInstance.bankURL;
+ const bankUser = args.provisionMerchantInstance.bankUser;
+ const bankPassword = args.provisionMerchantInstance.bankPassword;
+ const accountPayto = args.provisionMerchantInstance.payto as PaytoString;
+
+ const createResp = await api.createInstance(managementToken, {
+ address: {},
+ auth: {
+ method: "token",
+ token: instanceTokenPlain,
+ },
+ default_pay_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ default_wire_transfer_delay: { d_us: 1 },
+ id: instanceId,
+ jurisdiction: {},
+ name: instancceName,
+ use_stefan: true,
+ });
+
+ if (createResp.type === "ok") {
+ logger.info(`instance ${instanceId} created successfully`);
+ } else if (createResp.case === HttpStatusCode.Conflict) {
+ logger.info(`instance ${instanceId} already exists`);
+ } else {
+ logger.error(
+ `unable to create instance ${instanceId}, HTTP status ${createResp.case}`,
+ );
+ process.exit(2);
+ }
+
+ const createAccountResp = await api.addBankAccount(instanceTokenEnc, {
+ payto_uri: accountPayto,
+ credit_facade_url: bankURL,
+ credit_facade_credentials:
+ bankUser && bankPassword
+ ? {
+ type: "basic",
+ username: bankUser,
+ password: bankPassword,
+ }
+ : undefined,
+ });
+ if (createAccountResp.type != "ok") {
+ console.error(
+ `unable to configure bank account for instance ${instanceId}, status ${createAccountResp.case}`,
+ );
+ console.error(j2s(createAccountResp.detail));
+ process.exit(2);
+ }
+ logger.info(`successfully configured bank account for ${instanceId}`);
+ });
+
+deploymentCli
+ .subcommand("provisionBankAccount", "provision-bank-account", {
+ help: "Provision a corebank account.",
+ })
+ .requiredArgument("corebankApiBaseUrl", clk.STRING)
+ .flag("exchange", ["--exchange"])
+ .flag("public", ["--public"])
+ .requiredOption("login", ["--login"], clk.STRING)
+ .requiredOption("name", ["--name"], clk.STRING)
+ .requiredOption("password", ["--password"], clk.STRING)
+ .maybeOption("internalPayto", ["--payto"], clk.STRING)
+ .action(async (args) => {
+ const httpLib = createPlatformHttpLib();
+ const baseUrl = args.provisionBankAccount.corebankApiBaseUrl;
+ const api = new TalerCoreBankHttpClient(baseUrl, httpLib);
+
+ const accountLogin = args.provisionBankAccount.login;
+ const resp = await api.createAccount(undefined, {
+ name: args.provisionBankAccount.name,
+ password: args.provisionBankAccount.password,
+ username: accountLogin,
+ is_public: !!args.provisionBankAccount.public,
+ is_taler_exchange: !!args.provisionBankAccount.exchange,
+ payto_uri: args.provisionBankAccount.internalPayto as PaytoString,
+ });
+
+ if (resp.type === "ok") {
+ logger.info(`account ${accountLogin} successfully provisioned`);
+ return;
+ }
+ logger.error(
+ `unable to provision bank account, HTTP response status ${resp.case}`,
+ );
+ process.exit(2);
+ });
+
+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",
+ })
+ .flag("noFees", ["--no-fees"])
+ .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`;
+ if (args.coincfg.noFees) {
+ out += `FEE_DEPOSIT = ${currency}:0\n`;
+ } else {
+ 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 += `CIPHER = RSA\n`;
+ out += "\n";
+ x = Amounts.add(x, x).amount;
+ n++;
+ }
+
+ console.log(out);
+ });
+
+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.experimental) {
+ s += ` [experimental]`;
+ }
+ 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("experimental", ["--experimental"], {
+ help: "Include tests marked as experimental",
+ })
+ .flag("failFast", ["--fail-fast"], {
+ help: "Exit after the first error",
+ })
+ .flag("waitOnFail", ["--wait-on-fail"], {
+ help: "Exit after the first error",
+ })
+ .flag("quiet", ["--quiet"], {
+ help: "Produce less output.",
+ })
+ .flag("noTimeout", ["--no-timeout"], {
+ help: "Do not time out tests.",
+ })
+ .action(async (args) => {
+ await runTests({
+ includePattern: args.runIntegrationtests.pattern,
+ failFast: args.runIntegrationtests.failFast,
+ waitOnFail: args.runIntegrationtests.waitOnFail,
+ suiteSpec: args.runIntegrationtests.suites,
+ dryRun: args.runIntegrationtests.dryRun,
+ verbosity: args.runIntegrationtests.quiet ? 0 : 1,
+ includeExperimental: args.runIntegrationtests.experimental ?? false,
+ noTimeout: args.runIntegrationtests.noTimeout,
+ });
+ });
+
+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/test-age-restrictions-deposit.ts b/packages/taler-harness/src/integrationtests/test-age-restrictions-deposit.ts
new file mode 100644
index 000000000..a0e97c218
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-age-restrictions-deposit.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 {
+ AmountString,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, generateRandomPayto } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runAgeRestrictionsDepositTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange } =
+ await createSimpleTestkudosEnvironmentV3(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ ageMaskSpec: "8:10:12:14:16:18:21",
+ },
+ );
+
+ // Withdraw digital cash into the wallet.
+
+ const withdrawalResult = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawalResult.withdrawalFinishedCond;
+
+ const dgIdResp = await walletClient.client.call(
+ WalletApiOperation.GenerateDepositGroupTxId,
+ {},
+ );
+
+ const depositTxId = dgIdResp.transactionId;
+
+ const depositTrack = walletClient.waitForNotificationCond(
+ (n) =>
+ n.type == NotificationType.TransactionStateTransition &&
+ n.transactionId == depositTxId &&
+ n.newTxState.major == TransactionMajorState.Pending &&
+ n.newTxState.minor == TransactionMinorState.Track,
+ );
+
+ const depositDone = walletClient.waitForNotificationCond(
+ (n) =>
+ n.type == NotificationType.TransactionStateTransition &&
+ n.transactionId == depositTxId &&
+ n.newTxState.major == TransactionMajorState.Done,
+ );
+
+ const depositGroupResult = await walletClient.client.call(
+ WalletApiOperation.CreateDepositGroup,
+ {
+ amount: "TESTKUDOS:10" as AmountString,
+ depositPaytoUri: generateRandomPayto("foo"),
+ transactionId: depositTxId,
+ },
+ );
+
+ t.assertDeepEqual(depositGroupResult.transactionId, depositTxId);
+
+ await depositTrack;
+
+ await exchange.runAggregatorOnceWithTimetravel({
+ timetravelMicroseconds: 1000 * 1000 * 60 * 60 * 3,
+ });
+
+ await depositDone;
+
+ const transactions = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log("transactions", JSON.stringify(transactions, undefined, 2));
+ t.assertDeepEqual(transactions.transactions[0].type, "withdrawal");
+ t.assertDeepEqual(transactions.transactions[1].type, "deposit");
+ // 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");
+}
+
+runAgeRestrictionsDepositTest.suites = ["wallet"];
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..85bd96034
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-age-restrictions-merchant.ts
@@ -0,0 +1,174 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 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 { AmountString, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runAgeRestrictionsMerchantTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ walletClient: walletClientOne,
+ bankClient,
+ exchange,
+ merchant,
+ exchangeBankAccount,
+ } = await createSimpleTestkudosEnvironmentV3(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ ageMaskSpec: "8:10:12:14:16:18:21",
+ },
+ );
+
+ const { walletClient: walletClientTwo } = await createWalletDaemonWithClient(
+ t,
+ {
+ name: "w2",
+ },
+ );
+
+ const { walletClient: walletClientThree } =
+ await createWalletDaemonWithClient(t, {
+ name: "w3",
+ });
+
+ {
+ const { walletClient: walletClientZero } =
+ await createWalletDaemonWithClient(t, {
+ name: "w0",
+ });
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient: walletClientZero,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ restrictAge: 13,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPaymentV2(t, {
+ walletClient: walletClientZero,
+ merchant,
+ order,
+ });
+ await walletClientZero.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+
+ {
+ const walletClient = walletClientOne;
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ restrictAge: 13,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+
+ {
+ const walletClient = walletClientTwo;
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ restrictAge: 13,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+
+ {
+ const walletClient = walletClientThree;
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+}
+
+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..e822b15d8
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-age-restrictions-mixed-merchant.ts
@@ -0,0 +1,129 @@
+/*
+ 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 { AmountString, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runAgeRestrictionsMixedMerchantTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ walletClient: walletOne,
+ bankClient,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironmentV3(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ ageMaskSpec: "8:10:12:14:16:18:21",
+ mixedAgeRestriction: true,
+ },
+ );
+
+ const { walletClient: walletTwo } = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ });
+
+ const { walletClient: walletThree } = await createWalletDaemonWithClient(t, {
+ name: "w3",
+ });
+
+ {
+ const walletClient = walletOne;
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ restrictAge: 13,
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5" as AmountString,
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+
+ {
+ const wres = await withdrawViaBankV3(t, {
+ walletClient: walletTwo,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ restrictAge: 13,
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5" as AmountString,
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient: walletTwo, merchant, order });
+ await walletTwo.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ }
+
+ {
+ const wres = await withdrawViaBankV3(t, {
+ walletClient: walletThree,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPaymentV2(t, { walletClient: walletThree, merchant, order });
+ await walletThree.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ }
+}
+
+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..c9faa586a
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-age-restrictions-peer.ts
@@ -0,0 +1,134 @@
+/*
+ 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,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runAgeRestrictionsPeerTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bankClient, exchange } = await createSimpleTestkudosEnvironmentV3(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ ageMaskSpec: "8:10:12:14:16:18:21",
+ },
+ );
+
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ persistent: true,
+ });
+
+ const wallet1 = w1.walletClient;
+ const wallet2 = w2.walletClient;
+
+ {
+ const withdrawalRes = await withdrawViaBankV3(t, {
+ walletClient: wallet1,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ restrictAge: 13,
+ });
+
+ await withdrawalRes.withdrawalFinishedCond;
+
+ const purse_expiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const initResp = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "Hello, World",
+ amount: "TESTKUDOS:1" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ const peerPushReadyCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready &&
+ x.transactionId === initResp.transactionId,
+ );
+
+ await peerPushReadyCond;
+
+ const txDetails = await wallet1.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: initResp.transactionId,
+ },
+ );
+ t.assertDeepEqual(txDetails.type, TransactionType.PeerPushDebit);
+ t.assertTrue(!!txDetails.talerUri);
+
+ const checkResp = await wallet2.call(
+ WalletApiOperation.PreparePeerPushCredit,
+ {
+ talerUri: txDetails.talerUri,
+ },
+ );
+
+ await wallet2.call(WalletApiOperation.ConfirmPeerPushCredit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ const peerPullCreditDoneCond = wallet2.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Done &&
+ x.transactionId === checkResp.transactionId,
+ );
+
+ await peerPullCreditDoneCond;
+ }
+}
+
+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..58f8bb106
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-bank-api.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 {
+ TalerCorebankApiClient,
+ CreditDebitIndicator,
+ WireGatewayApiClient,
+ createEddsaKeyPair,
+ encodeCrock,
+} from "@gnu-taler/taler-util";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+
+/**
+ * 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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ let wireGatewayApiBaseUrl = new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href;
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ const bankUser = await bankClient.registerAccount("user1", "pw1");
+
+ // Make sure that registering twice results in a 409 Conflict
+ {
+ const e = await t.assertThrowsTalerErrorAsync(async () => {
+ await bankClient.registerAccount("user1", "pw2");
+ });
+ t.assertTrue(e.errorDetail.httpStatusCode === 409);
+ }
+
+ let balResp = await bankClient.getAccountBalance(bankUser.username);
+
+ 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();
+
+ const wireGatewayApiClient = new WireGatewayApiClient(
+ wireGatewayApiBaseUrl,
+ {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ },
+ );
+
+ await wireGatewayApiClient.adminAddIncoming({
+ amount: "TESTKUDOS:115",
+ debitAccountPayto: bankUser.accountPaytoUri,
+ reservePub: encodeCrock(res.eddsaPub),
+ });
+
+ balResp = await bankClient.getAccountBalance(bankUser.username);
+ t.assertAmountEquals(balResp.balance.amount, "TESTKUDOS:15");
+ t.assertTrue(
+ balResp.balance.credit_debit_indicator === CreditDebitIndicator.Debit,
+ );
+}
+
+runBankApiTest.suites = ["fakebank"]
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..01be6ea80
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-claim-loop.ts
@@ -0,0 +1,82 @@
+/*
+ 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 { URL } from "url";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+import { MerchantApiClient } from "@gnu-taler/taler-util";
+
+/**
+ * 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 { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Set up order.
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ // Query private order status before claiming it.
+ let orderStatusBefore = await merchantClient.queryPrivateOrderStatus({
+ 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 walletClient.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri,
+ });
+
+ // Query private order status after claiming it.
+ let orderStatusAfter = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+ t.assertTrue(orderStatusAfter.order_status === "claimed");
+
+ await t.shutdown();
+}
+
+runClaimLoopTest.suites = ["merchant"];
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..c104edc85
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-clause-schnorr.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 { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} 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 { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t, coinConfig);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Test JSON normalization of contract terms: Does the wallet
+ // agree with the merchant?
+ const order2: TalerMerchantApi.Order = {
+ summary: "Testing “unicode” characters",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order: order2 });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Test JSON normalization of contract terms: Does the wallet
+ // agree with the merchant?
+ const order3: TalerMerchantApi.Order = {
+ summary: "Testing\nNewlines\rAnd\tStuff\nHere\b",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order: order3 });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runClauseSchnorrTest.suites = ["experimental-wallet"];
+runClauseSchnorrTest.experimental = true;
diff --git a/packages/taler-harness/src/integrationtests/test-currency-scope.ts b/packages/taler-harness/src/integrationtests/test-currency-scope.ts
new file mode 100644
index 000000000..69e45f678
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-currency-scope.ts
@@ -0,0 +1,191 @@
+/*
+ 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, j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runCurrencyScopeTest(t: GlobalTestState) {
+ // Set up test environment
+ const dbDefault = await setupDb(t);
+
+ const dbExchangeTwo = await setupDb(t, {
+ nameSuffix: "exchange2",
+ });
+
+ const bank = await FakebankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: dbDefault.connStr,
+ httpPort: 8082,
+ });
+
+ const exchangeOne = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: dbDefault.connStr,
+ });
+
+ const exchangeTwo = ExchangeService.create(t, {
+ name: "testexchange-2",
+ currency: "TESTKUDOS",
+ httpPort: 8281,
+ database: dbExchangeTwo.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: dbDefault.connStr,
+ });
+
+ const exchangeOneBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ await exchangeOne.addBankAccount("1", exchangeOneBankAccount);
+
+ const exchangeTwoBankAccount = await bank.createExchangeAccount(
+ "myexchange2",
+ "x",
+ );
+ await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount);
+
+ bank.setSuggestedExchange(
+ exchangeOne,
+ exchangeOneBankAccount.accountPaytoUri,
+ );
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ // Set up the first exchange
+
+ exchangeOne.addOfferedCoins(defaultCoinConfig);
+ await exchangeOne.start();
+ await exchangeOne.pingUntilAvailable();
+
+ // Set up the second exchange
+
+ exchangeTwo.addOfferedCoins(defaultCoinConfig);
+ await exchangeTwo.start();
+ await exchangeTwo.pingUntilAvailable();
+
+ // Start and configure merchant
+
+ merchant.addExchange(exchangeOne);
+ merchant.addExchange(exchangeTwo);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wallet",
+ });
+
+ console.log("setup done!");
+
+ // Withdraw digital cash into the wallet.
+
+ const w1 = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: exchangeOne,
+ amount: "TESTKUDOS:6",
+ });
+
+ const w2 = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: exchangeTwo,
+ amount: "TESTKUDOS:6",
+ });
+
+ await w1.withdrawalFinishedCond;
+ await w2.withdrawalFinishedCond;
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(j2s(bal));
+
+ // Separate balances, exchange-scope.
+ t.assertDeepEqual(bal.balances.length, 2);
+
+ await walletClient.call(WalletApiOperation.AddGlobalCurrencyExchange, {
+ currency: "TESTKUDOS",
+ exchangeBaseUrl: exchangeOne.baseUrl,
+ exchangeMasterPub: exchangeOne.masterPub,
+ });
+
+ await walletClient.call(WalletApiOperation.AddGlobalCurrencyExchange, {
+ currency: "TESTKUDOS",
+ exchangeBaseUrl: exchangeTwo.baseUrl,
+ exchangeMasterPub: exchangeTwo.masterPub,
+ });
+
+ const ex = walletClient.call(
+ WalletApiOperation.ListGlobalCurrencyExchanges,
+ {},
+ );
+ console.log("global currency exchanges:");
+ console.log(j2s(ex));
+
+ const bal2 = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(j2s(bal2));
+
+ // Global currencies are merged
+ t.assertDeepEqual(bal2.balances.length, 1);
+}
+
+runCurrencyScopeTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-denom-lost.ts b/packages/taler-harness/src/integrationtests/test-denom-lost.ts
new file mode 100644
index 000000000..b57518437
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-denom-lost.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 { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for refreshe after a payment.
+ */
+export async function runDenomLostTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const dsBefore = await walletClient.call(
+ WalletApiOperation.TestingGetDenomStats,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+
+ t.assertDeepEqual(dsBefore.numLost, 0);
+ t.assertDeepEqual(dsBefore.numOffered, dsBefore.numKnown);
+
+ await exchange.stop();
+
+ await exchange.purgeSecmodKeys();
+
+ await exchange.start();
+
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ });
+
+ const dsAfter = await walletClient.call(
+ WalletApiOperation.TestingGetDenomStats,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+
+ // All previous denominations were lost
+ t.assertDeepEqual(dsBefore.numOffered, dsAfter.numLost);
+ // But we have new ones!
+ t.assertTrue(dsAfter.numKnown > dsBefore.numKnown);
+}
+
+runDenomLostTest.suites = ["wallet"];
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..8042c0817
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts
@@ -0,0 +1,164 @@
+/*
+ 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 {
+ MerchantApiClient,
+ PreparePayResultType,
+ TalerErrorCode,
+ TalerMerchantApi,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+export async function runDenomUnofferedTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ // Make the exchange forget the denomination.
+ // Effectively we completely reset the exchange,
+ // but keep the exchange master public key.
+
+ await merchant.stop();
+
+ await exchange.stop();
+ await exchange.purgeDatabase();
+ await exchange.purgeSecmodKeys();
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const orderResp = await merchantClient.createOrder({
+ order: order,
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const confirmResp = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ const tx = await walletClient.call(WalletApiOperation.GetTransactionById, {
+ transactionId: confirmResp.transactionId,
+ });
+
+ t.assertTrue(tx.error != null);
+ t.assertTrue(
+ tx.error.code === TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR,
+ );
+
+ const merchantErrorCode = (tx.error as any).requestError.errorResponse.code;
+
+ t.assertDeepEqual(
+ merchantErrorCode,
+ TalerErrorCode.MERCHANT_GENERIC_EXCHANGE_UNEXPECTED_STATUS,
+ );
+
+ const exchangeErrorCode = (tx.error as any).requestError.errorResponse
+ .exchange_ec;
+
+ t.assertDeepEqual(
+ exchangeErrorCode,
+ TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN,
+ );
+
+ // Depending on whether the merchant has seen the new denominations or not,
+ // the error code might be different here.
+ // t.assertDeepEqual(
+ // merchantErrorCode,
+ // TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND,
+ // );
+
+ // Force updating the exchange entry so that the wallet knows about the new denominations.
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ });
+
+ await walletClient.call(WalletApiOperation.DeleteTransaction, {
+ transactionId: confirmResp.transactionId,
+ });
+
+ // Now withdrawal should work again.
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txs = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ includeRefreshes: true,
+ });
+ console.log(JSON.stringify(txs, undefined, 2));
+
+ t.assertDeepEqual(txs.transactions[0].type, TransactionType.Withdrawal);
+ t.assertDeepEqual(txs.transactions[1].type, TransactionType.Refresh);
+ t.assertDeepEqual(txs.transactions[2].type, TransactionType.DenomLoss);
+ t.assertDeepEqual(txs.transactions[3].type, TransactionType.Withdrawal);
+}
+
+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..0879c9e9f
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-deposit.ts
@@ -0,0 +1,122 @@
+/*
+ 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 {
+ AmountString,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, generateRandomPayto } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runDepositTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const withdrawalResult = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawalResult.withdrawalFinishedCond;
+
+ const dgIdResp = await walletClient.client.call(
+ WalletApiOperation.GenerateDepositGroupTxId,
+ {},
+ );
+
+ const depositTxId = dgIdResp.transactionId;
+
+ const depositTrack = walletClient.waitForNotificationCond(
+ (n) =>
+ n.type == NotificationType.TransactionStateTransition &&
+ n.transactionId == depositTxId &&
+ n.newTxState.major == TransactionMajorState.Pending &&
+ n.newTxState.minor == TransactionMinorState.Track,
+ );
+
+ const depositDone = walletClient.waitForNotificationCond(
+ (n) =>
+ n.type == NotificationType.TransactionStateTransition &&
+ n.transactionId == depositTxId &&
+ n.newTxState.major == TransactionMajorState.Done,
+ );
+
+ const depositGroupResult = await walletClient.client.call(
+ WalletApiOperation.CreateDepositGroup,
+ {
+ amount: "TESTKUDOS:10" as AmountString,
+ depositPaytoUri: generateRandomPayto("foo"),
+ transactionId: depositTxId,
+ },
+ );
+
+ t.assertDeepEqual(depositGroupResult.transactionId, depositTxId);
+
+ const balDuring = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(`balances during deposit: ${j2s(balDuring)}`);
+ t.assertAmountEquals(balDuring.balances[0].pendingOutgoing, "TESTKUDOS:10");
+
+ await depositTrack;
+
+ t.logStep("before-aggregator");
+
+ await exchange.runAggregatorOnceWithTimetravel({
+ timetravelMicroseconds: 1000 * 1000 * 60 * 60 * 3,
+ });
+
+ await exchange.runTransferOnceWithTimetravel({
+ timetravelMicroseconds: 1000 * 1000 * 60 * 60 * 3,
+ });
+
+ await depositDone;
+
+ const transactions = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log("transactions", JSON.stringify(transactions, undefined, 2));
+ t.assertDeepEqual(transactions.transactions[0].type, "withdrawal");
+ t.assertDeepEqual(transactions.transactions[1].type, "deposit");
+ // 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 balAfter = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(`balances after deposit: ${j2s(balAfter)}`);
+ t.assertAmountEquals(balAfter.balances[0].pendingOutgoing, "TESTKUDOS:0");
+}
+
+runDepositTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-deposit.ts b/packages/taler-harness/src/integrationtests/test-exchange-deposit.ts
new file mode 100644
index 000000000..47a17a1f2
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-exchange-deposit.ts
@@ -0,0 +1,159 @@
+/*
+ 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 {
+ AmountString,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import {
+ CryptoDispatcher,
+ SynchronousCryptoWorkerFactoryPlain,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ checkReserve,
+ depositCoin,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ topupReserveWithBank,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core/dbless";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runExchangeDepositTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t);
+
+ const http = createPlatformHttpLib({
+ enableThrottling: false,
+ });
+ const cryptiDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptiDisp.cryptoApi;
+
+ try {
+ // Withdraw digital cash into the wallet.
+
+ const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http);
+
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+
+ await topupReserveWithBank({
+ http,
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeInfo,
+ reservePub: reserveKeyPair.pub,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await checkReserve(http, exchange.baseUrl, reserveKeyPair.pub);
+
+ const d1 = findDenomOrThrow(
+ exchangeInfo,
+ "TESTKUDOS:8" as AmountString,
+ {},
+ );
+
+ const coin = await withdrawCoin({
+ http,
+ cryptoApi,
+ reserveKeyPair: {
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ },
+ denom: d1,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const wireSalt = encodeCrock(getRandomBytes(16));
+ const merchantPub = encodeCrock(getRandomBytes(32));
+ const contractTermsHash = encodeCrock(getRandomBytes(64));
+
+ await depositCoin({
+ contractTermsHash,
+ merchantPub,
+ wireSalt,
+ amount: "TESTKUDOS:4" as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+
+ // Idempotency
+ await depositCoin({
+ contractTermsHash,
+ merchantPub,
+ wireSalt,
+ amount: "TESTKUDOS:4" as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+
+ try {
+ // Non-idempotent request with different amount
+ await depositCoin({
+ contractTermsHash,
+ merchantPub,
+ wireSalt,
+ amount: "TESTKUDOS:3.5" as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+ } catch (e) {
+ if (e instanceof TalerError && e.errorDetail.code === 7005) {
+ if (e.errorDetail.httpStatusCode === 409) {
+ console.log("got expected error response from exchange");
+ console.log(e);
+ console.log(j2s(e.errorDetail));
+ } else {
+ console.log("did not expect deposit error from exchange");
+ throw e;
+ }
+ } else {
+ throw e;
+ }
+ }
+ } catch (e) {
+ if (e instanceof TalerError) {
+ console.log(e);
+ console.log(j2s(e.errorDetail));
+ } else {
+ console.log(e);
+ }
+ throw e;
+ }
+}
+
+runExchangeDepositTest.suites = ["exchange"];
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts b/packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts
new file mode 100644
index 000000000..801162ac8
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts
@@ -0,0 +1,302 @@
+/*
+ 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 {
+ ExchangesListResponse,
+ TalerCorebankApiClient,
+ TalerErrorCode,
+ URL,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectionResponseContext,
+} from "../harness/faultInjection.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ WalletCli,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+
+/**
+ * Test if the wallet handles outdated exchange versions correctly.
+ */
+export async function runExchangeManagementFaultTest(
+ 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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
+ // Base URL must contain port that the proxy is listening on.
+ await exchange.modifyConfig(async (config) => {
+ config.setString("exchange", "base_url", "http://localhost:8091/");
+ });
+
+ bank.setSuggestedExchange(
+ faultyExchange,
+ exchangePaytoUri,
+ );
+
+ 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("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,
+ });
+ });
+
+ console.log("got error", err1);
+
+ // 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_EXCHANGE_UNAVAILABLE,
+ );
+
+ 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_UNAVAILABLE));
+
+ 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 bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ const user = await bankClient.createRandomBankUser();
+ bankClient.setAuth({
+ username: user.username,
+ password: user.password,
+ });
+
+ const wop = await bankClient.createWithdrawalOperation(
+ user.username,
+ "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);
+}
+
+runExchangeManagementFaultTest.suites = ["wallet", "exchange"];
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..072e9736d
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-exchange-management.ts
@@ -0,0 +1,82 @@
+/*
+ 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 { createSimpleTestkudosEnvironmentV3 } from "../harness/helpers.js";
+
+/**
+ * Test if the wallet handles outdated exchange versions correctly.
+ */
+export async function runExchangeManagementTest(
+ t: GlobalTestState,
+): Promise<void> {
+ // Set up test environment
+
+ const { walletClient, exchange } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Since the default exchanges can change, we start the wallet in tests
+ // with no built-in defaults. Thus the list of exchanges is empty here.
+ const exchangesListResult = await walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesListResult.exchanges.length, 0);
+
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ });
+
+ const exchangesListResult2 = await walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesListResult2.exchanges.length, 1);
+
+ await walletClient.call(WalletApiOperation.DeleteExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const exchangesListResult3 = await walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesListResult3.exchanges.length, 0);
+
+ // Check for regression: Can we re-add a deleted exchange?
+
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ });
+
+ const exchangesListResult4 = await walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesListResult4.exchanges.length, 1);
+}
+
+runExchangeManagementTest.suites = ["wallet", "exchange"];
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-purse.ts b/packages/taler-harness/src/integrationtests/test-exchange-purse.ts
new file mode 100644
index 000000000..6666e2d0b
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-exchange-purse.ts
@@ -0,0 +1,224 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 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,
+ AmountString,
+ ContractTermsUtil,
+ decodeCrock,
+ Duration,
+ encodeCrock,
+ getRandomBytes,
+ hash,
+ j2s,
+ PeerContractTerms,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import {
+ CryptoDispatcher,
+ EncryptContractRequest,
+ SpendCoinDetails,
+ SynchronousCryptoWorkerFactoryPlain,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ checkReserve,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ topupReserveWithBank,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core/dbless";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
+
+/**
+ * Test the exchange's purse API.
+ */
+export async function runExchangePurseTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t);
+
+ const http = harnessHttpLib;
+ const cryptoDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptoDisp.cryptoApi;
+
+ try {
+ // Withdraw digital cash into the wallet.
+
+ const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http);
+
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+
+ let reserveUrl = new URL(
+ `reserves/${reserveKeyPair.pub}`,
+ exchange.baseUrl,
+ );
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+ const longpollReq = http.fetch(reserveUrl.href, {
+ method: "GET",
+ });
+
+ await topupReserveWithBank({
+ amount: "TESTKUDOS:10" as AmountString,
+ http,
+ reservePub: reserveKeyPair.pub,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeInfo,
+ });
+
+ console.log("waiting for longpoll request");
+ const resp = await longpollReq;
+ console.log(`got response, status ${resp.status}`);
+
+ console.log(exchangeInfo);
+
+ await checkReserve(http, exchange.baseUrl, reserveKeyPair.pub);
+
+ const d1 = findDenomOrThrow(
+ exchangeInfo,
+ "TESTKUDOS:8" as AmountString,
+ {},
+ );
+
+ const coin = await withdrawCoin({
+ http,
+ cryptoApi,
+ reserveKeyPair: {
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ },
+ denom: d1,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const amount = "TESTKUDOS:5" as AmountString;
+
+ const contractTerms: PeerContractTerms = {
+ amount,
+ summary: "Hello",
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ ),
+ };
+
+ const mergeReservePair = await cryptoApi.createEddsaKeypair({});
+ const pursePair = await cryptoApi.createEddsaKeypair({});
+ const mergePair = await cryptoApi.createEddsaKeypair({});
+ const contractPair = await cryptoApi.createEddsaKeypair({});
+ const contractEncNonce = encodeCrock(getRandomBytes(24));
+
+ const pursePub = pursePair.pub;
+
+ const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ const purseSigResp = await cryptoApi.signPurseCreation({
+ hContractTerms,
+ mergePub: mergePair.pub,
+ minAge: 0,
+ purseAmount: amount,
+ purseExpiration: contractTerms.purse_expiration,
+ pursePriv: pursePair.priv,
+ });
+
+ const coinSpend: SpendCoinDetails = {
+ ageCommitmentProof: undefined,
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contribution: amount,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ };
+
+ const depositSigsResp = await cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: exchange.baseUrl,
+ pursePub: pursePair.pub,
+ coins: [coinSpend],
+ });
+
+ const encryptContractRequest: EncryptContractRequest = {
+ contractTerms: contractTerms,
+ mergePriv: mergePair.priv,
+ pursePriv: pursePair.priv,
+ pursePub: pursePair.pub,
+ contractPriv: contractPair.priv,
+ contractPub: contractPair.pub,
+ nonce: contractEncNonce,
+ };
+
+ const econtractResp = await cryptoApi.encryptContractForMerge(
+ encryptContractRequest,
+ );
+
+ const econtractHash = encodeCrock(
+ hash(decodeCrock(econtractResp.econtract.econtract)),
+ );
+
+ const createPurseUrl = new URL(
+ `purses/${pursePair.pub}/create`,
+ exchange.baseUrl,
+ );
+
+ const reqBody = {
+ amount: amount,
+ merge_pub: mergePair.pub,
+ purse_sig: purseSigResp.sig,
+ h_contract_terms: hContractTerms,
+ purse_expiration: contractTerms.purse_expiration,
+ deposits: depositSigsResp.deposits,
+ min_age: 0,
+ econtract: econtractResp.econtract,
+ };
+
+ const httpResp = await http.fetch(createPurseUrl.href, {
+ method: "POST",
+ body: reqBody,
+ });
+
+ const respBody = await httpResp.json();
+
+ console.log("status", httpResp.status);
+
+ console.log(j2s(respBody));
+
+ const mergeUrl = new URL(`purses/${pursePub}/merge`, exchange.baseUrl);
+ mergeUrl.searchParams.set("timeout_ms", "300");
+ const statusResp = await http.fetch(mergeUrl.href, {});
+
+ const statusRespBody = await statusResp.json();
+
+ console.log(j2s(statusRespBody));
+
+ t.assertTrue(statusRespBody.merge_timestamp === undefined);
+ } catch (e) {
+ if (e instanceof TalerError) {
+ console.log(e);
+ console.log(j2s(e.errorDetail));
+ } else {
+ console.log(e);
+ }
+ throw e;
+ }
+}
+
+runExchangePurseTest.suites = ["wallet"];
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..4f2fb1ee4
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts
@@ -0,0 +1,287 @@
+/*
+ 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,
+ DenomKeyType,
+ Duration,
+ ExchangeKeysJson,
+ Logger,
+ TalerCorebankApiClient,
+} from "@gnu-taler/taler-util";
+import {
+ createPlatformHttpLib,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-util/http";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ generateRandomPayto,
+ GlobalTestState,
+ MerchantService,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ applyTimeTravelV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+const logger = new Logger("test-exchange-timetravel.ts");
+
+interface DenomInfo {
+ denomPub: DenominationPubKey;
+ expireDeposit: string;
+}
+
+function getDenomInfoFromKeys(ek: ExchangeKeysJson): DenomInfo[] {
+ const denomInfos: DenomInfo[] = [];
+ for (const denomGroup of ek.denominations) {
+ switch (denomGroup.cipher) {
+ case "RSA":
+ case "RSA+age_restricted": {
+ let ageMask = 0;
+ if (denomGroup.cipher === "RSA+age_restricted") {
+ ageMask = denomGroup.age_mask;
+ }
+ for (const denomIn of denomGroup.denoms) {
+ const denomPub: DenominationPubKey = {
+ age_mask: ageMask,
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key: denomIn.rsa_pub,
+ };
+ denomInfos.push({
+ denomPub,
+ expireDeposit: AbsoluteTime.stringify(
+ AbsoluteTime.fromProtocolTimestamp(denomIn.stamp_expire_deposit),
+ ),
+ });
+ }
+ break;
+ }
+ case "CS+age_restricted":
+ case "CS":
+ logger.warn("Clause-Schnorr denominations not supported");
+ continue;
+ default:
+ logger.warn(
+ `denomination type ${(denomGroup as any).cipher} not supported`,
+ );
+ continue;
+ }
+ }
+ return denomInfos;
+}
+
+const http = createPlatformHttpLib({
+ enableThrottling: false,
+});
+
+/**
+ * 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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ exchange.addCoinConfigList(makeNoFeeCoinConfig("TESTKUDOS"));
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
+ });
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:15",
+ });
+ await wres.withdrawalFinishedCond;
+
+ const keysResp1 = await http.fetch(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 applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ days: 400 })),
+ {
+ walletClient,
+ exchange,
+ merchant,
+ },
+ );
+
+ const keysResp2 = await http.fetch(exchange.baseUrl + "keys");
+ const keys2 = await readSuccessResponseJsonOrThrow(
+ keysResp2,
+ codecForExchangeKeysJson(),
+ );
+ console.log(
+ "keys 2 (after time travel):",
+ JSON.stringify(keys2, undefined, 2),
+ );
+
+ const denomPubs1 = getDenomInfoFromKeys(keys1);
+ const denomPubs2 = getDenomInfoFromKeys(keys2);
+
+ const dps2 = new Set(denomPubs2.map((x) => x.denomPub));
+
+ console.log("=== KEYS RESPONSE 1 ===");
+
+ console.log(
+ "list issue date",
+ AbsoluteTime.stringify(
+ AbsoluteTime.fromProtocolTimestamp(keys1.list_issue_date),
+ ),
+ );
+ console.log("num denoms", denomPubs1.length);
+ console.log("denoms", JSON.stringify(denomPubs1, undefined, 2));
+
+ console.log("=== KEYS RESPONSE 2 ===");
+
+ console.log(
+ "list issue date",
+ AbsoluteTime.stringify(
+ AbsoluteTime.fromProtocolTimestamp(keys2.list_issue_date),
+ ),
+ );
+ console.log("num denoms", denomPubs2.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.fromProtocolTimestamp(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..6ae7b5de8
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-fee-regression.ts
@@ -0,0 +1,241 @@
+/*
+ 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 {
+ TalerCorebankApiClient,
+ TalerMerchantApi,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ SimpleTestEnvironmentNg3,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} 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<SimpleTestEnvironmentNg3> {
+ 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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL(
+ "accounts/exchange/taler-wire-gateway/",
+ bank.baseUrl,
+ ).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ 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.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ {
+ name: "w1",
+ },
+ );
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ walletService,
+ bankClient,
+ exchangeBankAccount: {
+ accountName: "",
+ accountPassword: "",
+ accountPaytoUri: "",
+ wireGatewayApiBaseUrl: "",
+ },
+ };
+}
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runFeeRegressionTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createMyTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:1.92",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const coins = await walletClient.call(WalletApiOperation.DumpCoins, {});
+
+ // Make sure we really withdraw one 0.64 and one 1.28 coin.
+ t.assertTrue(coins.coins.length === 2);
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:1.30",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txs = await walletClient.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..839ddd927
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-forced-selection.ts
@@ -0,0 +1,86 @@
+/*
+ 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 { AmountString, j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
+
+/**
+ * Run test for forced denom/coin selection.
+ */
+export async function runForcedSelectionTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ await walletClient.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ await walletClient.call(WalletApiOperation.WithdrawTestBalance, {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ forcedDenomSel: {
+ denoms: [
+ {
+ value: "TESTKUDOS:2" as AmountString,
+ count: 3,
+ },
+ ],
+ },
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {});
+ console.log(coinDump);
+ t.assertDeepEqual(coinDump.coins.length, 3);
+
+ const payResp = await walletClient.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:3" as AmountString,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ summary: "bla",
+ forcedCoinSel: {
+ coins: [
+ {
+ value: "TESTKUDOS:2" as AmountString,
+ contribution: "TESTKUDOS:1" as AmountString,
+ },
+ {
+ value: "TESTKUDOS:2" as AmountString,
+ contribution: "TESTKUDOS:1" as AmountString,
+ },
+ {
+ value: "TESTKUDOS:2" as AmountString,
+ contribution: "TESTKUDOS:1" as AmountString,
+ },
+ ],
+ },
+ });
+
+ console.log(j2s(payResp));
+
+ // Without forced selection, we would only use 2 coins.
+ t.assertDeepEqual(payResp.numCoins, 3);
+}
+
+runForcedSelectionTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-kyc.ts b/packages/taler-harness/src/integrationtests/test-kyc.ts
new file mode 100644
index 000000000..213dd9df4
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-kyc.ts
@@ -0,0 +1,451 @@
+/*
+ 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,
+ Logger,
+ NotificationType,
+ TalerCorebankApiClient,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import * as http from "node:http";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ WalletClient,
+ WalletService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import { EnvOptions, SimpleTestEnvironmentNg3 } from "../harness/helpers.js";
+
+const logger = new Logger("test-kyc.ts");
+
+export async function createKycTestkudosEnvironment(
+ t: GlobalTestState,
+ coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ opts: EnvOptions = {},
+): Promise<SimpleTestEnvironmentNg3> {
+ 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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ 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.modifyConfig(async (config) => {
+ const myprov = "kyc-provider-myprov";
+ config.setString(myprov, "cost", "0");
+ config.setString(myprov, "logic", "oauth2");
+ config.setString(myprov, "provided_checks", "dummy1");
+ config.setString(myprov, "user_type", "individual");
+ config.setString(myprov, "kyc_oauth2_validity", "forever");
+ config.setString(
+ myprov,
+ "kyc_oauth2_token_url",
+ "http://localhost:6666/oauth/v2/token",
+ );
+ config.setString(
+ myprov,
+ "kyc_oauth2_authorize_url",
+ "http://localhost:6666/oauth/v2/login",
+ );
+ config.setString(
+ myprov,
+ "kyc_oauth2_info_url",
+ "http://localhost:6666/oauth/v2/info",
+ );
+ config.setString(
+ myprov,
+ "kyc_oauth2_converter_helper",
+ "taler-exchange-kyc-oauth2-test-converter.sh",
+ );
+ config.setString(myprov, "kyc_oauth2_client_id", "taler-exchange");
+ config.setString(myprov, "kyc_oauth2_client_secret", "exchange-secret");
+ config.setString(myprov, "kyc_oauth2_post_url", "https://taler.net");
+
+ config.setString(
+ "kyc-legitimization-withdraw1",
+ "operation_type",
+ "withdraw",
+ );
+ config.setString(
+ "kyc-legitimization-withdraw1",
+ "required_checks",
+ "dummy1",
+ );
+ config.setString("kyc-legitimization-withdraw1", "timeframe", "1d");
+ config.setString(
+ "kyc-legitimization-withdraw1",
+ "threshold",
+ "TESTKUDOS:5",
+ );
+ });
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const walletService = new WalletService(t, {
+ name: "wallet",
+ useInMemoryDb: true,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const walletClient = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ onNotification(n) {
+ console.log("got notification", n);
+ },
+ });
+ await walletClient.connect();
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ skipDefaults: true,
+ },
+ },
+ });
+
+ console.log("setup done!");
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ walletService,
+ bankClient,
+ exchangeBankAccount: {
+ accountName: '',
+ accountPassword: '',
+ accountPaytoUri: '',
+ wireGatewayApiBaseUrl: '',
+ },
+ };
+}
+
+interface TestfakeKycService {
+ stop: () => void;
+}
+
+function splitInTwoAt(s: string, separator: string): [string, string] {
+ const idx = s.indexOf(separator);
+ if (idx === -1) {
+ return [s, ""];
+ }
+ return [s.slice(0, idx), s.slice(idx + 1)];
+}
+
+/**
+ * Testfake for the kyc service that the exchange talks to.
+ */
+async function runTestfakeKycService(): Promise<TestfakeKycService> {
+ const server = http.createServer((req, res) => {
+ const requestUrl = req.url!;
+ logger.info(`kyc: got ${req.method} request, ${requestUrl}`);
+
+ const [path, query] = splitInTwoAt(requestUrl, "?");
+
+ const qp = new URLSearchParams(query);
+
+ if (path === "/oauth/v2/login") {
+ // Usually this would render some HTML page for the user to log in,
+ // but we return JSON here.
+ const redirUriUnparsed = qp.get("redirect_uri");
+ if (!redirUriUnparsed) {
+ throw Error("missing redirect_url");
+ }
+ const state = qp.get("state");
+ if (!state) {
+ throw Error("missing state");
+ }
+ const redirUri = new URL(redirUriUnparsed);
+ redirUri.searchParams.set("code", "code_is_ok");
+ redirUri.searchParams.set("state", state);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ redirect_uri: redirUri.href,
+ }),
+ );
+ } else if (path === "/oauth/v2/token") {
+ let reqBody = "";
+ req.on("data", (x) => {
+ reqBody += x;
+ });
+
+ req.on("end", () => {
+ logger.info("login request body:", reqBody);
+
+ res.writeHead(200, { "Content-Type": "application/json" });
+ // Normally, the access_token would also include which user we're trying
+ // to get info about, but we (for now) skip it in this test.
+ res.end(
+ JSON.stringify({
+ access_token: "exchange_access_token",
+ token_type: "Bearer",
+ }),
+ );
+ });
+ } else if (path === "/oauth/v2/info") {
+ logger.info("authorization header:", req.headers.authorization);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ status: "success",
+ data: {
+ id: "foobar",
+ },
+ }),
+ );
+ } else {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ code: 1, message: "bad request" }));
+ }
+ });
+ await new Promise<void>((resolve, reject) => {
+ server.listen(6666, () => resolve());
+ });
+ return {
+ stop() {
+ server.close();
+ },
+ };
+}
+
+export async function runKycTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createKycTestkudosEnvironment(t);
+
+ const kycServer = await runTestfakeKycService();
+
+ // Withdraw digital cash into the wallet.
+
+ const amount = "TESTKUDOS:20";
+ const user = await bankClient.createRandomBankUser();
+ bankClient.setAuth({
+ username: user.username,
+ password: user.password,
+ });
+
+ const wop = await bankClient.createWithdrawalOperation(user.username, amount);
+
+ // Hand it to the wallet
+
+ await walletClient.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ // Withdraw
+
+ const acceptResp = await walletClient.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ const withdrawalTxId = acceptResp.transactionId;
+
+ // Confirm it
+
+ await bankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ const kycNotificationCond = walletClient.waitForNotificationCond((x) => {
+ if (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === withdrawalTxId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.KycRequired
+ ) {
+ return x;
+ }
+ return false;
+ });
+
+ const withdrawalDoneCond = walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === withdrawalTxId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ const kycNotif = await kycNotificationCond;
+
+ logger.info("got kyc notification:", j2s(kycNotif));
+
+ const txState = await walletClient.client.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: withdrawalTxId,
+ },
+ );
+
+ t.assertDeepEqual(txState.type, TransactionType.Withdrawal);
+
+ const kycUrl = txState.kycUrl;
+
+ t.assertTrue(!!kycUrl);
+
+ logger.info(`kyc URL is ${kycUrl}`);
+
+ // We now simulate the user interacting with the KYC service,
+ // which would usually done in the browser.
+
+ const httpLib = createPlatformHttpLib({
+ enableThrottling: false,
+ });
+ const kycServerResp = await httpLib.fetch(kycUrl);
+ const kycLoginResp = await kycServerResp.json();
+ logger.info(`kyc server resp: ${j2s(kycLoginResp)}`);
+ const kycProofUrl = kycLoginResp.redirect_uri;
+ // We need to "visit" the KYC proof URL at least once to trigger the exchange
+ // asking for the KYC status.
+ const proofHttpResp = await httpLib.fetch(kycProofUrl);
+ logger.info(`proof resp status ${proofHttpResp.status}`);
+ logger.info(`resp headers ${j2s(proofHttpResp.headers.toJSON())}`);
+ if (
+ !(proofHttpResp.status >= 200 && proofHttpResp.status <= 299) &&
+ proofHttpResp.status !== 303
+ ) {
+ logger.error("kyc proof failed");
+ logger.info(await proofHttpResp.text());
+ t.assertTrue(false);
+ }
+
+ // Now that KYC is done, withdrawal should finally succeed.
+
+ await withdrawalDoneCond;
+
+ kycServer.stop();
+}
+
+runKycTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-bank.ts b/packages/taler-harness/src/integrationtests/test-libeufin-bank.ts
new file mode 100644
index 000000000..01b20ddbf
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-bank.ts
@@ -0,0 +1,229 @@
+/*
+ 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 {
+ CreditDebitIndicator,
+ Logger,
+ TalerCorebankApiClient,
+ TransactionMajorState,
+ TransactionMinorState,
+ WireGatewayApiClient,
+ createEddsaKeyPair,
+ encodeCrock,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ GlobalTestState,
+ LibeufinBankService,
+ MerchantService,
+ generateRandomPayto,
+ generateRandomTestIban,
+ setupDb,
+} from "../harness/harness.js";
+import { createWalletDaemonWithClient } from "../harness/helpers.js";
+
+const logger = new Logger("test-libeufin-bank.ts");
+
+/**
+ * Run test for the basic functionality of libeufin-bank.
+ */
+export async function runLibeufinBankTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await LibeufinBankService.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 exchangeBankUsername = "exchange";
+ const exchangeBankPw = "mypw";
+ const exchangePayto = generateRandomPayto(exchangeBankUsername);
+ const wireGatewayApiBaseUrl = new URL(
+ "accounts/exchange/taler-wire-gateway/",
+ bank.baseUrl,
+ ).href;
+
+ logger.info("creating bank account for the exchange");
+
+ exchange.addBankAccount("1", {
+ wireGatewayApiBaseUrl,
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPw,
+ accountPaytoUri: exchangePayto,
+ });
+
+ bank.setSuggestedExchange(exchange);
+
+ 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wallet",
+ });
+
+ console.log("setup done!");
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ // register exchange bank account
+ await bankClient.registerAccountExtended({
+ name: "Exchange",
+ password: exchangeBankPw,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePayto,
+ });
+
+ const bankUser = await bankClient.registerAccount("user1", "pw1");
+ bankClient.setAuth({
+ username: "user1",
+ password: "pw1",
+ });
+
+ // Make sure that registering twice results in a 409 Conflict
+ // {
+ // const e = await t.assertThrowsTalerErrorAsync(async () => {
+ // await bankClient.registerAccount("user1", "pw2");
+ // });
+ // t.assertTrue(e.errorDetail.httpStatusCode === 409);
+ // }
+
+ let balResp = await bankClient.getAccountBalance(bankUser.username);
+
+ 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();
+
+ // Not a normal client, but one with admin credentials,
+ // as /add-incoming is testing functionality only allowed by the admin.
+ const wireGatewayApiAdminClient = new WireGatewayApiClient(
+ wireGatewayApiBaseUrl,
+ {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ },
+ );
+
+ await wireGatewayApiAdminClient.adminAddIncoming({
+ amount: "TESTKUDOS:115",
+ debitAccountPayto: bankUser.accountPaytoUri,
+ reservePub: encodeCrock(res.eddsaPub),
+ });
+
+ balResp = await bankClient.getAccountBalance(bankUser.username);
+ t.assertAmountEquals(balResp.balance.amount, "TESTKUDOS:15");
+ t.assertTrue(
+ balResp.balance.credit_debit_indicator === CreditDebitIndicator.Debit,
+ );
+
+ const wop = await bankClient.createWithdrawalOperation(
+ bankUser.username,
+ "TESTKUDOS:10",
+ );
+
+ const r1 = await walletClient.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ console.log(j2s(r1));
+
+ const r2 = await walletClient.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: r2.transactionId,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ },
+ });
+
+ await bankClient.confirmWithdrawalOperation(bankUser.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runLibeufinBankTest.suites = ["fakebank"];
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..19f89ae2c
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts
@@ -0,0 +1,264 @@
+/*
+ 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,
+ MerchantApiClient,
+ PreparePayResultType,
+ TalerCorebankApiClient,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { URL } from "url";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectedMerchantService,
+} from "../harness/faultInjection.js";
+import {
+ BankService,
+ ExchangeService,
+ generateRandomPayto,
+ GlobalTestState,
+ harnessHttpLib,
+ MerchantService,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ createWalletDaemonWithClient,
+ FaultyMerchantTestEnvironmentNg,
+ withdrawViaBankV3,
+} 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<FaultyMerchantTestEnvironmentNg> {
+ 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);
+
+ // Base URL must contain port that the proxy is listening on.
+ await exchange.modifyConfig(async (config) => {
+ config.setString("exchange", "base_url", "http://localhost:9081/");
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
+ });
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ bankClient,
+ 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 { walletClient, bankClient, faultyExchange, faultyMerchant } =
+ await createConfusedMerchantTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange: faultyExchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ /**
+ * =========================================================================
+ * 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;
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ let orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 harnessHttpLib.fetch(publicOrderStatusUrl);
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ await publicOrderStatusResp.json(),
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await walletClient.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 harnessHttpLib.fetch(orderUrlWithHash.href);
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ await publicOrderStatusResp.json(),
+ );
+
+ const confirmPayRes = await walletClient.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..c0c9353e4
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-instances-delete.ts
@@ -0,0 +1,137 @@
+/*
+ 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 { MerchantApiClient, TalerError, URL } from "@gnu-taler/taler-util";
+import {
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ harnessHttpLib,
+ setupDb,
+} 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 harnessHttpLib.fetch(new URL("config", baseUrl).href);
+ const data = await r.json();
+ console.log(data);
+ t.assertDeepEqual(data.currency, "TESTKUDOS");
+ }
+
+ // Instances should initially be empty
+ {
+ const r = await harnessHttpLib.fetch(
+ new URL("management/instances", baseUrl).href,
+ );
+ const data = await r.json();
+ t.assertDeepEqual(data.instances, []);
+ }
+
+ // Add an instance, no auth!
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+
+ // Add an instance, no auth!
+ await merchant.addInstanceWithWireAccount({
+ id: "myinst",
+ name: "Second Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+
+ let merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
+ auth: {
+ method: "external",
+ },
+ });
+
+ await merchantClient.changeAuth({
+ method: "token",
+ token: "secret-token:foobar",
+ });
+
+ merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
+ auth: {
+ method: "token",
+ token: "secret-token:foobar",
+ },
+ });
+
+ // Check that deleting an instance checks the auth
+ // of the default instance.
+ {
+ const unauthMerchantClient = new MerchantApiClient(
+ merchant.makeInstanceBaseUrl(),
+ {
+ auth: {
+ method: "token",
+ token: "secret-token:invalid",
+ },
+ },
+ );
+
+ const exc = await t.assertThrowsAsync(async () => {
+ await unauthMerchantClient.deleteInstance("myinst");
+ });
+ console.log("Got expected exception", exc);
+ t.assertTrue(exc instanceof TalerError);
+ t.assertDeepEqual(exc.errorDetail.httpStatusCode, 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..b631ea1a4
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts
@@ -0,0 +1,179 @@
+/*
+ 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, MerchantApiClient } from "@gnu-taler/taler-util";
+import {
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ harnessHttpLib,
+ setupDb,
+} from "../harness/harness.js";
+
+/**
+ * Do basic checks on instance management and authentication.
+ */
+export async function runMerchantInstancesUrlsTest(t: GlobalTestState) {
+ 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(),
+ {
+ auth: {
+ method: "token",
+ token: "secret-token:i-am-default",
+ },
+ },
+ );
+
+ await clientForDefault.createInstance({
+ id: "default",
+ address: {},
+ use_stefan: true,
+ default_pay_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
+ default_wire_transfer_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
+ jurisdiction: {},
+ name: "My Default Instance",
+ auth: {
+ method: "token",
+ token: "secret-token:i-am-default",
+ },
+ });
+
+ await clientForDefault.createInstance({
+ id: "myinst",
+ address: {},
+ default_pay_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
+ use_stefan: true,
+ default_wire_transfer_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
+ jurisdiction: {},
+ name: "My Second Instance",
+ auth: {
+ method: "token",
+ token: "secret-token:i-am-myinst",
+ },
+ });
+
+ async function check(url: string, token: string, expectedStatus: number) {
+ const resp = await harnessHttpLib.fetch(url, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+ 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..188451e15
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-instances.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 { MerchantApiClient, URL } from "@gnu-taler/taler-util";
+import {
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ harnessHttpLib,
+ setupDb,
+} 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 harnessHttpLib.fetch(new URL("config", baseUrl).href);
+ const data = await r.json();
+ console.log(data);
+ t.assertDeepEqual(data.currency, "TESTKUDOS");
+ }
+
+ // Instances should initially be empty
+ {
+ const r = await harnessHttpLib.fetch(
+ new URL("management/instances", baseUrl).href,
+ );
+ const data = await r.json();
+ t.assertDeepEqual(data.instances, []);
+ }
+
+ // Add an instance, no auth!
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+
+ // Add it again, should be idempotent
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+
+ // Add an instance, no auth!
+ await merchant.addInstanceWithWireAccount({
+ id: "myinst",
+ name: "Second Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+
+ let merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
+ auth: {
+ 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 harnessHttpLib.fetch(
+ 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.assertTrue(exc.errorDetail.httpStatusCode === 401);
+
+ merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
+ auth: {
+ 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 harnessHttpLib.fetch(
+ 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(),
+ {
+ auth: {
+ method: "external",
+ },
+ },
+ );
+
+ const exc = await t.assertThrowsAsync(async () => {
+ await unauthMerchantClient.deleteInstance("myinst");
+ });
+ console.log(exc);
+ t.assertTrue(exc.errorDetail.httpStatusCode === 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..656fc4ded
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-longpolling.ts
@@ -0,0 +1,165 @@
+/*
+ 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,
+ MerchantApiClient,
+ PreparePayResultType,
+ URL,
+ codecForMerchantOrderStatusUnpaid,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runMerchantLongpollingTest(t: GlobalTestState) {
+ // Set up test environment
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ /**
+ * =========================================================================
+ * 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 merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ },
+ create_token: false,
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
+
+ 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 harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming, with long-polling), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ await publicOrderStatusResp.json(),
+ );
+
+ console.log(pubUnpaidStatus);
+
+ /**
+ * =========================================================================
+ * Now actually pay, but WHILE a long poll is active!
+ * =========================================================================
+ */
+
+ let preparePayResp = await walletClient.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 = harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
+
+ 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(
+ await publicOrderStatusResp.json(),
+ );
+
+ const confirmPayRes = await walletClient.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..1d712f745
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-refund-api.ts
@@ -0,0 +1,302 @@
+/*
+ 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,
+ MerchantApiClient,
+ PreparePayResultType,
+ URL,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ ExchangeServiceInterface,
+ GlobalTestState,
+ MerchantServiceInterface,
+ WalletClient,
+ harnessHttpLib,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+async function testRefundApiWithFulfillmentUrl(
+ t: GlobalTestState,
+ env: {
+ merchant: MerchantServiceInterface;
+ walletClient: WalletClient;
+ exchange: ExchangeServiceInterface;
+ },
+): Promise<void> {
+ const { walletClient, merchant } = env;
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Set up order.
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/fulfillment",
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 5 }),
+ ),
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ proposalId: preparePayResult.proposalId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.AlreadyConfirmed,
+ );
+
+ await merchantClient.giveRefund({
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
+ const respData = await publicOrderStatusResp.json();
+ t.assertTrue(publicOrderStatusResp.status === 200);
+ t.assertAmountEquals(respData.refund_amount, "TESTKUDOS:5");
+
+ publicOrderStatusUrl = new URL(
+ `orders/${orderId}`,
+ merchant.makeInstanceBaseUrl(),
+ );
+ console.log(`requesting order status via '${publicOrderStatusUrl.href}'`);
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl.href);
+ console.log(publicOrderStatusResp.status);
+ console.log(await publicOrderStatusResp.json());
+ // 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;
+ walletClient: WalletClient;
+ exchange: ExchangeServiceInterface;
+ },
+): Promise<void> {
+ const { walletClient, merchant } = env;
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Set up order.
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_message: "Thank you for buying foobar",
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 5 }),
+ ),
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ proposalId: preparePayResult.proposalId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.AlreadyConfirmed,
+ );
+
+ await merchantClient.giveRefund({
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
+ let respData = await publicOrderStatusResp.json();
+ console.log(respData);
+ t.assertTrue(publicOrderStatusResp.status === 200);
+ t.assertAmountEquals(respData.refund_amount, "TESTKUDOS:5");
+
+ publicOrderStatusUrl = new URL(
+ `orders/${orderId}`,
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl.href);
+ respData = await publicOrderStatusResp.json();
+ console.log(respData);
+ // 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 { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+
+ await testRefundApiWithFulfillmentUrl(t, {
+ walletClient,
+ exchange,
+ merchant,
+ });
+
+ await testRefundApiWithFulfillmentMessage(t, {
+ walletClient,
+ 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..8a22eae57
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts
@@ -0,0 +1,631 @@
+/*
+ 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,
+ MerchantApiClient,
+ PreparePayResultType,
+ TalerCorebankApiClient,
+ URL,
+ encodeCrock,
+ getRandomBytes,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ harnessHttpLib,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+interface Context {
+ merchant: MerchantService;
+ merchantBaseUrl: string;
+ bankClient: TalerCorebankApiClient;
+ exchange: ExchangeService;
+}
+
+const httpLib = harnessHttpLib;
+
+async function testWithClaimToken(
+ t: GlobalTestState,
+ c: Context,
+): Promise<void> {
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wct",
+ });
+ const { bankClient, exchange } = c;
+ const { merchant, merchantBaseUrl } = c;
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+ const sessionId = "mysession";
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+ const orderResp = await merchantClient.createOrder({
+ 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.fetch(
+ 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.fetch(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.fetch(url.href, {
+ headers: {
+ Accept: "text/html",
+ },
+ });
+ const r = await httpResp.text();
+ t.assertDeepEqual(httpResp.status, 402);
+ console.log(r);
+ }
+
+ const preparePayResp = await walletClient.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.fetch(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.fetch(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.fetch(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.fetch(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.fetch(url.href);
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(httpResp.status, 202);
+ }
+
+ const confirmPayRes = await walletClient.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.fetch(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.fetch(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.fetch(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.fetch(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.fetch(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.fetch(url.href, {
+ headers: { Accept: "text/html" },
+ });
+ t.assertDeepEqual(httpResp.status, 200);
+ }
+
+ const confirmPayRes2 = await walletClient.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 merchantClient.createOrder({
+ 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.fetch(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.fetch(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.fetch(url.href, {
+ headers: { Accept: "text/html" },
+ redirect: "manual",
+ });
+ console.log(
+ `requesting GET ${url.href}, expected 302 got ${httpResp.status}`,
+ );
+ 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 { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wnoct",
+ });
+ const sessionId = "mysession2";
+ const { bankClient, exchange } = c;
+ const { merchant, merchantBaseUrl } = c;
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+ const orderResp = await merchantClient.createOrder({
+ 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.fetch(
+ 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.fetch(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.fetch(url.href, {
+ headers: {
+ Accept: "text/html",
+ },
+ });
+ const r = await httpResp.text();
+ t.assertDeepEqual(httpResp.status, 402);
+ console.log(r);
+ }
+
+ const preparePayResp = await walletClient.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.fetch(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.fetch(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.fetch(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.fetch(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.fetch(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 walletClient.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.fetch(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.fetch(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.fetch(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.fetch(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.fetch(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.fetch(url.href, {
+ headers: { Accept: "text/html" },
+ });
+ t.assertDeepEqual(httpResp.status, 200);
+ }
+
+ const confirmPayRes2 = await walletClient.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 merchantClient.createOrder({
+ 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.fetch(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.fetch(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.fetch(url.href, {
+ headers: { Accept: "text/html" },
+ redirect: "manual",
+ });
+ 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 { bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Base URL for the default instance.
+ const merchantBaseUrl = merchant.makeInstanceBaseUrl();
+
+ {
+ const httpResp = await httpLib.fetch(
+ new URL("config", merchantBaseUrl).href,
+ );
+ const r = await httpResp.json();
+ console.log(r);
+ t.assertDeepEqual(r.currency, "TESTKUDOS");
+ }
+
+ {
+ const httpResp = await httpLib.fetch(
+ 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.fetch(
+ 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,
+ bankClient,
+ });
+
+ await testWithoutClaimToken(t, {
+ merchant,
+ merchantBaseUrl,
+ exchange,
+ bankClient,
+ });
+}
+
+runMerchantSpecPublicOrdersTest.suites = ["merchant"];
diff --git a/packages/taler-harness/src/integrationtests/test-multiexchange.ts b/packages/taler-harness/src/integrationtests/test-multiexchange.ts
new file mode 100644
index 000000000..b5cf0770f
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-multiexchange.ts
@@ -0,0 +1,172 @@
+/*
+ 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, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runMultiExchangeTest(t: GlobalTestState) {
+ // Set up test environment
+ const dbDefault = await setupDb(t);
+
+ const dbExchangeTwo = await setupDb(t, {
+ nameSuffix: "exchange2",
+ });
+
+ const bank = await FakebankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: dbDefault.connStr,
+ httpPort: 8082,
+ });
+
+ const exchangeOne = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: dbDefault.connStr,
+ });
+
+ const exchangeTwo = ExchangeService.create(t, {
+ name: "testexchange-2",
+ currency: "TESTKUDOS",
+ httpPort: 8281,
+ database: dbExchangeTwo.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: dbDefault.connStr,
+ });
+
+ const exchangeOneBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ await exchangeOne.addBankAccount("1", exchangeOneBankAccount);
+
+ const exchangeTwoBankAccount = await bank.createExchangeAccount(
+ "myexchange2",
+ "x",
+ );
+ await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount);
+
+ bank.setSuggestedExchange(
+ exchangeOne,
+ exchangeOneBankAccount.accountPaytoUri,
+ );
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ // Set up the first exchange
+
+ exchangeOne.addOfferedCoins(defaultCoinConfig);
+ await exchangeOne.start();
+ await exchangeOne.pingUntilAvailable();
+
+ // Set up the second exchange
+
+ exchangeTwo.addOfferedCoins(defaultCoinConfig);
+ await exchangeTwo.start();
+ await exchangeTwo.pingUntilAvailable();
+
+ // Start and configure merchant
+
+ merchant.addExchange(exchangeOne);
+ merchant.addExchange(exchangeTwo);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ { name: "wallet" },
+ );
+
+ console.log("setup done!");
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: exchangeOne,
+ amount: "TESTKUDOS:6",
+ });
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: exchangeTwo,
+ amount: "TESTKUDOS:6",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:10",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ console.log("making test payment");
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runMultiExchangeTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-otp.ts b/packages/taler-harness/src/integrationtests/test-otp.ts
new file mode 100644
index 000000000..4fcc8c6e9
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-otp.ts
@@ -0,0 +1,119 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 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,
+ MerchantApiClient,
+ PreparePayResultType,
+ TransactionType,
+ j2s,
+ narrowOpSuccessOrThrow,
+ randomRfc3548Base32Key,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runOtpTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+ const createOtpRes = await merchantClient.createOtpDevice({
+ otp_algorithm: 1,
+ otp_device_description: "Hello",
+ otp_device_id: "mydevice",
+ otp_key: randomRfc3548Base32Key(),
+ });
+ narrowOpSuccessOrThrow("createOtpDevice", createOtpRes);
+
+ const createTemplateRes = await merchantClient.createTemplate({
+ template_description: "my template",
+ template_id: "tpl1",
+ otp_id: "mydevice",
+ template_contract: {
+ summary: "test",
+ amount: "TESTKUDOS:1",
+ minimum_age: 0,
+ pay_duration: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ },
+ });
+ narrowOpSuccessOrThrow("createTemplate", createTemplateRes);
+
+ const getTemplateResp = await merchantClient.getTemplate("tpl1");
+ narrowOpSuccessOrThrow("getTemplate", getTemplateResp);
+
+ console.log(`template: ${j2s(getTemplateResp.body)}`);
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForTemplate,
+ {
+ talerPayTemplateUri: `taler+http://pay-template/localhost:${merchant.port}/tpl1`,
+ templateParams: {},
+ },
+ );
+
+ console.log(preparePayResult);
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ // Pay for it
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ const transaction = await walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: preparePayResult.transactionId,
+ },
+ );
+
+ console.log(j2s(transaction));
+
+ t.assertTrue(transaction.type === TransactionType.Payment);
+ t.assertTrue(transaction.posConfirmation != null);
+ t.assertTrue(transaction.posConfirmation.length > 10);
+}
+
+runOtpTest.suites = ["wallet"];
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..3d93f6e29
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-pay-paid.ts
@@ -0,0 +1,213 @@
+/*
+ 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,
+ MerchantApiClient,
+ PreparePayResultType,
+ URL,
+ codecForMerchantOrderStatusUnpaid,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { FaultInjectionRequestContext } from "../harness/faultInjection.js";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import {
+ createFaultInjectedMerchantTestkudosEnvironment,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * 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 { walletClient, bank, faultyExchange, faultyMerchant } =
+ await createFaultInjectedMerchantTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: faultyExchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ /**
+ * =========================================================================
+ * 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;
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ let orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 harnessHttpLib.fetch(publicOrderStatusUrl);
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.json(),
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ const proposalId = preparePayResp.proposalId;
+
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.json(),
+ );
+
+ const confirmPayRes = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ proposalId: proposalId,
+ });
+
+ t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
+
+ console.log(publicOrderStatusResp.json());
+
+ if (publicOrderStatusResp.status != 200) {
+ console.log(publicOrderStatusResp.json());
+ throw Error(
+ `expected status 200 (after paying), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ /**
+ * =========================================================================
+ * Now change up the session ID and do payment re-play!
+ * =========================================================================
+ */
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ });
+
+ let orderStatusTwo = await merchantClient.queryPrivateOrderStatus({
+ 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 walletClient.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-abort.ts b/packages/taler-harness/src/integrationtests/test-payment-abort.ts
new file mode 100644
index 000000000..ca8384411
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-abort.ts
@@ -0,0 +1,164 @@
+/*
+ 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,
+ MerchantApiClient,
+ PreparePayResultType,
+ TalerErrorCode,
+ TalerErrorDetail,
+ URL,
+ codecForMerchantOrderStatusUnpaid,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { FaultInjectionRequestContext } from "../harness/faultInjection.js";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import {
+ createFaultInjectedMerchantTestkudosEnvironment,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+export async function runPaymentAbortTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, faultyMerchant, faultyExchange } =
+ await createFaultInjectedMerchantTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: faultyExchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const merchantClient = new MerchantApiClient(
+ faultyMerchant.makeInstanceBaseUrl(),
+ );
+
+ let orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 harnessHttpLib.fetch(publicOrderStatusUrl);
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.json(),
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.json(),
+ );
+
+ faultyMerchant.faultProxy.addFault({
+ async modifyRequest(ctx: FaultInjectionRequestContext) {
+ const url = new URL(ctx.requestUrl);
+ if (!url.pathname.endsWith("/pay")) {
+ return;
+ }
+ ctx.dropRequest = true;
+ const err: TalerErrorDetail = {
+ code: TalerErrorCode.GENERIC_CONFIGURATION_INVALID,
+ hint: "something went wrong",
+ };
+ ctx.substituteResponseStatusCode = 404;
+ ctx.substituteResponseBody = Buffer.from(JSON.stringify(err));
+ console.log("injecting pay fault");
+ },
+ });
+
+ const confirmPayResp = await walletClient.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ transactionId: preparePayResp.transactionId,
+ },
+ );
+
+ // Can't have succeeded yet, but network error results in "pending" state.
+ t.assertDeepEqual(confirmPayResp.type, ConfirmPayResultType.Pending);
+
+ const txns = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ });
+ console.log(j2s(txns));
+
+ await walletClient.call(WalletApiOperation.AbortTransaction, {
+ transactionId: txns.transactions[1].transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txns2 = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ });
+ console.log(j2s(txns2));
+
+ const txTypes = txns2.transactions.map((x) => x.type);
+ console.log(txTypes);
+ t.assertDeepEqual(txTypes, ["withdrawal", "payment", "refund"]);
+
+ // FIXME: also check extended transaction list for refresh.
+ // FIXME: also check balance
+}
+
+runPaymentAbortTest.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..dfadd9539
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-claim.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 {
+ MerchantApiClient,
+ PreparePayResultType,
+ TalerErrorCode,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test where a wallet tries to claim an already claimed order.
+ */
+export async function runPaymentClaimTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const w2 = await createWalletDaemonWithClient(t, { name: "w2" });
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ // Set up order.
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const errOne = t.assertThrowsTalerErrorAsync(async () => {
+ await w2.walletClient.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri,
+ });
+ });
+
+ console.log(errOne);
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ await w2.walletClient.call(WalletApiOperation.ClearDb, {});
+
+ const err = await t.assertThrowsTalerErrorAsync(async () => {
+ await w2.walletClient.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-deleted.ts b/packages/taler-harness/src/integrationtests/test-payment-deleted.ts
new file mode 100644
index 000000000..bab8a4df1
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-deleted.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 {
+ ConfirmPayResultType,
+ MerchantApiClient,
+ PreparePayResultType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Test behavior when an order is deleted while the wallet is paying for it.
+ */
+export async function runPaymentDeletedTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // First, make a "free" payment when we don't even have
+ // any money in the
+
+ // Withdraw digital cash into the wallet.
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Hello",
+ amount: "TESTKUDOS:2",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ await merchantClient.deleteOrder({
+ orderId: orderResp.order_id,
+ force: true,
+ });
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Pending);
+
+ await walletClient.call(WalletApiOperation.AbortTransaction, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(j2s(bal));
+}
+
+runPaymentDeletedTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-payment-expired.ts b/packages/taler-harness/src/integrationtests/test-payment-expired.ts
new file mode 100644
index 000000000..3f1f7f2dd
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-expired.ts
@@ -0,0 +1,132 @@
+/*
+ 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,
+ ConfirmPayResultType,
+ Duration,
+ MerchantApiClient,
+ PreparePayResultType,
+ TalerMerchantApi,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ applyTimeTravelV2,
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run a test for the following scenario:
+ *
+ * - Wallet claims an order
+ * - Merchant goes down
+ * - Wallet tried to pay, but it fails as the merchant is unavailable
+ * - The order expires
+ * - The merchant goes back up again
+ * - Instead of trying to get an abort-refund, the wallet notices that
+ * the order is expired, puts the transaction into "failed",
+ * refreshes allocated coins and thus raises the balance again.
+ */
+export async function runPaymentExpiredTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Order that can only be paid within five minutes.
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ minutes: 5 }),
+ ),
+ ),
+ };
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const orderResp = await merchantClient.createOrder({
+ order,
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertDeepEqual(
+ preparePayResult.status,
+ PreparePayResultType.PaymentPossible,
+ );
+
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ hours: 1 })),
+ { walletClient, exchange, merchant },
+ );
+
+ const confirmPayResult = await walletClient.call(
+ WalletApiOperation.ConfirmPay,
+ { transactionId: preparePayResult.transactionId },
+ );
+ console.log("confirm pay result:");
+ console.log(j2s(confirmPayResult));
+ t.assertDeepEqual(confirmPayResult.type, ConfirmPayResultType.Pending);
+ await walletClient.call(WalletApiOperation.AbortTransaction, {
+ transactionId: preparePayResult.transactionId,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txns = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ includeRefreshes: true,
+ });
+ console.log(j2s(txns));
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(j2s(bal));
+
+ t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:18.93");
+}
+
+runPaymentExpiredTest.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..dabe42a6b
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-fault.ts
@@ -0,0 +1,234 @@
+/*
+ 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 {
+ ConfirmPayResultType,
+ MerchantApiClient,
+ TalerCorebankApiClient,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectionRequestContext,
+ FaultInjectionResponseContext,
+} from "../harness/faultInjection.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * 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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL(
+ "accounts/exchange/taler-wire-gateway/",
+ bank.baseUrl,
+ ).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
+ // Base URL must contain port that the proxy is listening on.
+ await exchange.modifyConfig(async (config) => {
+ config.setString("exchange", "base_url", "http://localhost:8091/");
+ });
+
+ bank.setSuggestedExchange(faultyExchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ // 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ console.log("setup done!");
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
+ });
+
+ await walletClient.call(WalletApiOperation.GetBalances, {});
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange: faultyExchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ // Set up order.
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const prepResp = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ // Drop 3 responses from the exchange.
+ let faultCount = 0;
+ faultyExchange.faultProxy.addFault({
+ async modifyResponse(ctx: FaultInjectionResponseContext) {
+ console.log(`in modifyResponse for ${ctx.request.requestUrl}`);
+ if (
+ !ctx.request.requestUrl.endsWith("/deposit") &&
+ !ctx.request.requestUrl.endsWith("/batch-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
+
+ const confirmPayResp = await walletClient.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ transactionId: prepResp.transactionId,
+ },
+ );
+
+ t.assertDeepEqual(confirmPayResp.type, ConfirmPayResultType.Pending);
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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..827c299a4
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-forgettable.ts
@@ -0,0 +1,86 @@
+/*
+ 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 {
+ createSimpleTestkudosEnvironmentV3,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+/**
+ * Run test for payment with a contract that has forgettable fields.
+ */
+export async function runPaymentForgettableTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ {
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ extra: {
+ foo: { bar: "baz" },
+ $forgettable: {
+ foo: "gnu",
+ },
+ },
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ }
+
+ console.log("testing with forgettable field without hash");
+
+ {
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ extra: {
+ foo: { bar: "baz" },
+ $forgettable: {
+ foo: true,
+ },
+ },
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ }
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+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..4a8e95af3
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-idempotency.ts
@@ -0,0 +1,130 @@
+/*
+ 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 { MerchantApiClient, PreparePayResultType } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * 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 { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Set up order.
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ const preparePayResultRep = await walletClient.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 walletClient.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId: proposalId,
+ },
+ );
+
+ console.log("confirm pay result", confirmPayResult);
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ const preparePayResultAfter = await walletClient.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..3c902ee17
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-multiple.ts
@@ -0,0 +1,193 @@
+/*
+ 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 { MerchantApiClient, TalerCorebankApiClient } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { coin_ct10, coin_u1 } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+async function setupTest(t: GlobalTestState): Promise<{
+ merchant: MerchantService;
+ exchange: ExchangeService;
+ bankClient: TalerCorebankApiClient;
+}> {
+ 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,
+ });
+
+ exchange.addOfferedCoins([coin_ct10, coin_u1]);
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ return {
+ merchant,
+ bankClient,
+ exchange,
+ };
+}
+
+/**
+ * Run test.
+ *
+ * This test uses a very sub-optimal denomination structure.
+ */
+export async function runPaymentMultipleTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { merchant, bankClient, exchange } = await setupTest(t);
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
+ });
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:100",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ // Set up order.
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:80",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: r1.transactionId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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-share.ts b/packages/taler-harness/src/integrationtests/test-payment-share.ts
new file mode 100644
index 000000000..25cfb50c6
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-share.ts
@@ -0,0 +1,309 @@
+/*
+ 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 {
+ AmountString,
+ ConfirmPayResultType,
+ MerchantApiClient,
+ NotificationType,
+ PreparePayResultType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runPaymentShareTest(t: GlobalTestState) {
+ // Set up test environment
+ const {
+ walletClient: firstWallet,
+ bankClient,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Withdraw digital cash into the wallet.
+ await withdrawViaBankV3(t, {
+ walletClient: firstWallet,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await firstWallet.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const { walletClient: secondWallet } = await createWalletDaemonWithClient(t, {
+ name: "wallet2",
+ });
+
+ await withdrawViaBankV3(t, {
+ walletClient: secondWallet,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await secondWallet.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ {
+ const first = await firstWallet.call(WalletApiOperation.GetBalances, {});
+ const second = await secondWallet.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals(first.balances[0].available, "TESTKUDOS:19.53");
+ t.assertAmountEquals(second.balances[0].available, "TESTKUDOS:19.53");
+ }
+
+ t.logStep("setup-done");
+
+ // create two orders to pay
+ async function createOrder(amount: string) {
+ const order = {
+ summary: "Buy me!",
+ amount: amount as AmountString,
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ const args = { order };
+
+ const orderResp = await merchantClient.createOrder({
+ order: args.order,
+ });
+
+ const orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+ return { id: orderResp.order_id, uri: orderStatus.taler_pay_uri };
+ }
+
+ t.logStep("orders-created");
+
+ /**
+ * Case 1:
+ * - Claim with first wallet and pay in the second wallet.
+ * - First wallet should be notified.
+ */
+ {
+ const order = await createOrder("TESTKUDOS:5");
+ // Claim the order with the first wallet
+ const claimFirstWallet = await firstWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: order.uri },
+ );
+
+ t.assertTrue(
+ claimFirstWallet.status === PreparePayResultType.PaymentPossible,
+ );
+
+ t.logStep("w1-payment-possible");
+
+ // share order from the first wallet
+ const { privatePayUri } = await firstWallet.call(
+ WalletApiOperation.SharePayment,
+ {
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ orderId: order.id,
+ },
+ );
+
+ t.logStep("w1-payment-shared");
+
+ // claim from the second wallet
+ const claimSecondWallet = await secondWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: privatePayUri },
+ );
+
+ t.assertTrue(
+ claimSecondWallet.status === PreparePayResultType.PaymentPossible,
+ );
+
+ t.logStep("w2-claimed");
+
+ // pay from the second wallet
+ const r2 = await secondWallet.call(WalletApiOperation.ConfirmPay, {
+ transactionId: claimSecondWallet.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ t.logStep("w2-confirmed");
+
+ // Wait for refresh to settle before we do checks
+ await secondWallet.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ t.logStep("w2-refresh-settled");
+
+ {
+ const first = await firstWallet.call(WalletApiOperation.GetBalances, {});
+ const second = await secondWallet.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ t.assertAmountEquals(first.balances[0].available, "TESTKUDOS:19.53");
+ t.assertAmountEquals(second.balances[0].available, "TESTKUDOS:14.23");
+ }
+
+ t.logStep("wait-for-payment");
+ // firstWallet.waitForNotificationCond(n =>
+ // n.type === NotificationType.TransactionStateTransition &&
+ // n.transactionId === claimFirstWallet.transactionId
+ // )
+ // Claim the order with the first wallet
+ const claimFirstWalletAgain = await firstWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: order.uri },
+ );
+
+ t.assertTrue(
+ claimFirstWalletAgain.status === PreparePayResultType.AlreadyConfirmed,
+ );
+ t.assertTrue( claimFirstWalletAgain.paid );
+
+ t.logStep("w1-prepared-again");
+
+ const r1 = await firstWallet.call(WalletApiOperation.ConfirmPay, {
+ transactionId: claimFirstWallet.transactionId,
+ });
+
+ //t.assertTrue(r1.type === ConfirmPayResultType.Pending);
+
+ t.logStep("w1-confirmed-shared");
+
+ await firstWallet.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ await secondWallet.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ /**
+ * only the second wallet balance was affected
+ */
+ {
+ const first = await firstWallet.call(WalletApiOperation.GetBalances, {});
+ const second = await secondWallet.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ t.assertAmountEquals(first.balances[0].available, "TESTKUDOS:19.53");
+ t.assertAmountEquals(second.balances[0].available, "TESTKUDOS:14.23");
+ }
+ }
+
+ t.logStep("first-case-done");
+
+ /**
+ * Case 2:
+ * - Claim with first wallet and share with the second wallet
+ * - Pay with the first wallet, second wallet should be notified
+ */
+ {
+ const order = await createOrder("TESTKUDOS:3");
+ // Claim the order with the first wallet
+ const claimFirstWallet = await firstWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: order.uri },
+ );
+
+ t.assertTrue(
+ claimFirstWallet.status === PreparePayResultType.PaymentPossible,
+ );
+
+ t.logStep("case2-w1-claimed");
+
+ // share order from the first wallet
+ const { privatePayUri } = await firstWallet.call(
+ WalletApiOperation.SharePayment,
+ {
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ orderId: order.id,
+ },
+ );
+
+ t.logStep("case2-w1-shared");
+
+ // claim from the second wallet
+ const claimSecondWallet = await secondWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: privatePayUri },
+ );
+
+ t.logStep("case2-w2-prepared");
+
+ t.assertTrue(
+ claimSecondWallet.status === PreparePayResultType.PaymentPossible,
+ );
+
+ // pay from the first wallet
+ const r2 = await firstWallet.call(WalletApiOperation.ConfirmPay, {
+ transactionId: claimFirstWallet.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ // Wait for refreshes to settle before doing checks
+ await firstWallet.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ /**
+ * only the first wallet balance was affected
+ */
+ const bal1 = await firstWallet.call(WalletApiOperation.GetBalances, {});
+ const bal2 = await secondWallet.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals(bal1.balances[0].available, "TESTKUDOS:16.18");
+ t.assertAmountEquals(bal2.balances[0].available, "TESTKUDOS:14.23");
+
+ t.logStep("wait-for-payment");
+ // secondWallet.waitForNotificationCond(n =>
+ // n.type === NotificationType.TransactionStateTransition &&
+ // n.transactionId === claimSecondWallet.transactionId
+ // )
+
+ // Claim the order with the first wallet
+ const claimSecondWalletAgain = await secondWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: order.uri },
+ );
+
+ t.assertTrue(
+ claimSecondWalletAgain.status === PreparePayResultType.AlreadyConfirmed,
+ );
+ t.assertTrue(
+ claimSecondWalletAgain.paid,
+ );
+
+ }
+
+ t.logStep("second-case-done");
+}
+
+runPaymentShareTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-payment-template.ts b/packages/taler-harness/src/integrationtests/test-payment-template.ts
new file mode 100644
index 000000000..451a7dbe9
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-template.ts
@@ -0,0 +1,125 @@
+/*
+ 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 {
+ AmountString,
+ ConfirmPayResultType,
+ Duration,
+ MerchantApiClient,
+ PreparePayResultType,
+ narrowOpSuccessOrThrow,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Test for taler://payment-template/ URIs
+ */
+export async function runPaymentTemplateTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const mySummary = "hello, I'm a summary";
+
+ const createTemplateRes = await merchantClient.createTemplate({
+ template_id: "template1",
+ template_description: "my test template",
+ template_contract: {
+ minimum_age: 0,
+ pay_duration: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({
+ minutes: 2,
+ }),
+ ),
+ summary: mySummary,
+ },
+ editable_defaults: {
+ amount: "TESTKUDOS:1" as AmountString,
+ },
+ });
+ narrowOpSuccessOrThrow("createTemplate", createTemplateRes);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+
+ const talerPayTemplateUri = `taler+http://pay-template/localhost:${merchant.port}/template1`;
+
+ const checkPayTemplateResult = await walletClient.call(
+ WalletApiOperation.CheckPayForTemplate,
+ {
+ talerPayTemplateUri,
+ },
+ );
+
+ t.assertDeepEqual(
+ checkPayTemplateResult.template_contract.summary,
+ mySummary,
+ );
+
+ // Request a template payment
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForTemplate,
+ {
+ talerPayTemplateUri,
+ templateParams: {},
+ },
+ );
+
+ console.log(preparePayResult);
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ // Pay for it
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ // Check if payment was successful.
+
+ const orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: preparePayResult.contractTerms.order_id,
+ instance: "default",
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runPaymentTemplateTest.suites = ["wallet"];
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..1911b5e92
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-transient.ts
@@ -0,0 +1,179 @@
+/*
+ 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,
+ MerchantApiClient,
+ PreparePayResultType,
+ TalerErrorCode,
+ TalerErrorDetail,
+ URL,
+ codecForMerchantOrderStatusUnpaid,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { FaultInjectionResponseContext } from "../harness/faultInjection.js";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import {
+ createFaultInjectedMerchantTestkudosEnvironment,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * 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 { walletClient, bank, faultyMerchant, faultyExchange } =
+ await createFaultInjectedMerchantTestkudosEnvironment(t);
+
+ const merchantClient = new MerchantApiClient(
+ faultyMerchant.makeInstanceBaseUrl(),
+ );
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: faultyExchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ let orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 harnessHttpLib.fetch(publicOrderStatusUrl);
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.json(),
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ const proposalId = preparePayResp.proposalId;
+
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.json(),
+ );
+
+ 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 walletClient.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId,
+ },
+ );
+
+ console.log(confirmPayResp);
+
+ t.assertTrue(confirmPayResp.type === ConfirmPayResultType.Pending);
+ t.assertTrue(faultInjected);
+
+ const confirmPayRespTwo = await walletClient.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId,
+ },
+ );
+
+ t.assertTrue(confirmPayRespTwo.type === ConfirmPayResultType.Done);
+
+ // Now ask the merchant if paid
+
+ console.log("requesting", publicOrderStatusUrl);
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
+
+ console.log(publicOrderStatusResp.json());
+
+ if (publicOrderStatusResp.status != 200) {
+ console.log(publicOrderStatusResp.json());
+ 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..3a74a9cf2
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-zero.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 { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+ makeTestPaymentV2,
+} from "../harness/helpers.js";
+import { TransactionMajorState } from "@gnu-taler/taler-util";
+
+/**
+ * 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 { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // First, make a "free" payment when we don't even have
+ // any money in the
+
+ // Withdraw digital cash into the wallet.
+ await withdrawViaBankV3(t, { walletClient, bankClient, exchange, amount: "TESTKUDOS:20" });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ await makeTestPaymentV2(t, {
+ walletClient,
+ merchant,
+ order: {
+ summary: "I am free!",
+ amount: "TESTKUDOS:0",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const transactions = await walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ for (const tr of transactions.transactions) {
+ t.assertDeepEqual(tr.txState.major, TransactionMajorState.Done);
+ }
+}
+
+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..5da6d608d
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment.ts
@@ -0,0 +1,88 @@
+/*
+ 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 { TalerMerchantApi, j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runPaymentTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bankClient, walletClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ t.assertTrue(bankClient !== undefined);
+ await withdrawViaBankV3(t, {
+ walletClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ bankClient,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ } satisfies TalerMerchantApi.Order;
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // 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",
+ } satisfies TalerMerchantApi.Order;
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order: order2 });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // 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",
+ } satisfies TalerMerchantApi.Order;
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order: order3 });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(`balance after 3 payments: ${j2s(bal)}`);
+ t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:3.8");
+ t.assertAmountEquals(bal.balances[0].pendingIncoming, "TESTKUDOS:0");
+ t.assertAmountEquals(bal.balances[0].pendingOutgoing, "TESTKUDOS:0");
+}
+
+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..de3961dec
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-paywall-flow.ts
@@ -0,0 +1,250 @@
+/*
+ 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,
+ MerchantApiClient,
+ PreparePayResultType,
+ URL,
+ codecForMerchantOrderStatusUnpaid,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runPaywallFlowTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ /**
+ * =========================================================================
+ * 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 merchantClient.createOrder({
+ 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 merchantClient.queryPrivateOrderStatus({
+ 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 harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.json(),
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await walletClient.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 harnessHttpLib.fetch(publicOrderStatusUrl.href);
+ console.log("response body", publicOrderStatusResp.json());
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.json(),
+ );
+
+ const confirmPayRes = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ proposalId: proposalId,
+ });
+
+ t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl.href);
+
+ console.log(publicOrderStatusResp.json());
+
+ if (publicOrderStatusResp.status != 200) {
+ console.log(publicOrderStatusResp.json());
+ throw Error(
+ `expected status 200 (after paying), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ /**
+ * =========================================================================
+ * Now change up the session ID!
+ * =========================================================================
+ */
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: talerPayUriOne,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.AlreadyConfirmed);
+ t.assertTrue(preparePayResp.paid);
+
+ /**
+ * =========================================================================
+ * Now we test re-purchase detection.
+ * =========================================================================
+ */
+
+ orderResp = await merchantClient.createOrder({
+ 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 merchantClient.queryPrivateOrderStatus({
+ 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 walletClient.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 merchantClient.queryPrivateOrderStatus({
+ 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 merchantClient.queryPrivateOrderStatus({
+ 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 harnessHttpLib.fetch(publicOrderStatusUrl.href);
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(`expected status 402, but got ${publicOrderStatusResp.status}`);
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.json(),
+ );
+
+ console.log(publicOrderStatusResp.json());
+
+ t.assertTrue(pubUnpaidStatus.already_paid_order_id === firstOrderId);
+}
+
+runPaywallFlowTest.suites = ["merchant", "wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull-large.ts b/packages/taler-harness/src/integrationtests/test-peer-pull-large.ts
new file mode 100644
index 000000000..6de3c2e33
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-pull-large.ts
@@ -0,0 +1,194 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 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,
+ AmountString,
+ Duration,
+ j2s,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import {
+ BankServiceHandle,
+ ExchangeService,
+ GlobalTestState,
+ WalletClient,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+];
+
+export async function runPeerPullLargeTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(
+ t,
+ coinConfigList,
+ );
+
+ let allW1Notifications: WalletNotification[] = [];
+ let allW2Notifications: WalletNotification[] = [];
+
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ persistent: true,
+ handleNotification(wn) {
+ allW2Notifications.push(wn);
+ },
+ });
+
+ // Withdraw digital cash into the wallet.
+ const wallet1 = w1.walletClient;
+ const wallet2 = w2.walletClient;
+
+ await checkNormalPeerPull(t, bank, exchange, wallet1, wallet2);
+}
+
+async function checkNormalPeerPull(
+ t: GlobalTestState,
+ bank: BankServiceHandle,
+ exchange: ExchangeService,
+ wallet1: WalletClient,
+ wallet2: WalletClient,
+): Promise<void> {
+ const withdrawRes = await withdrawViaBankV2(t, {
+ walletClient: wallet2,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:500",
+ });
+
+ await withdrawRes.withdrawalFinishedCond;
+
+ const purseExpiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const resp = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:200" as AmountString,
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+
+ const peerPullCreditReadyCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPullCreditReadyCond;
+
+ const creditTx = await wallet1.call(WalletApiOperation.GetTransactionById, {
+ transactionId: resp.transactionId,
+ });
+
+ t.assertDeepEqual(creditTx.type, TransactionType.PeerPullCredit);
+ t.assertTrue(!!creditTx.talerUri);
+
+ const checkResp = await wallet2.client.call(
+ WalletApiOperation.PreparePeerPullDebit,
+ {
+ talerUri: creditTx.talerUri,
+ },
+ );
+
+ console.log(`checkResp: ${j2s(checkResp)}`);
+
+ const peerPullCreditDoneCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ const peerPullDebitDoneCond = wallet2.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === checkResp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ await wallet2.client.call(WalletApiOperation.ConfirmPeerPullDebit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ await peerPullCreditDoneCond;
+ await peerPullDebitDoneCond;
+
+ 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)}`);
+}
+
+runPeerPullLargeTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-peer-push-large.ts b/packages/taler-harness/src/integrationtests/test-peer-push-large.ts
new file mode 100644
index 000000000..b7fbe9f6e
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-push-large.ts
@@ -0,0 +1,177 @@
+/*
+ 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,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WalletNotification,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+import { CoinConfig } from "../harness/denomStructures.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+];
+
+/**
+ * Run a test for a multi-batch peer push payment.
+ */
+export async function runPeerPushLargeTest(t: GlobalTestState) {
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t, coinConfigList);
+
+ let allW1Notifications: WalletNotification[] = [];
+ let allW2Notifications: WalletNotification[] = [];
+
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ handleNotification(wn) {
+ allW2Notifications.push(wn);
+ },
+ });
+
+ // Withdraw digital cash into the wallet.
+
+ const withdrawRes = await withdrawViaBankV2(t, {
+ walletClient: w1.walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:300",
+ });
+
+ await withdrawRes.withdrawalFinishedCond;
+
+ const purse_expiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const checkResp0 = await w1.walletClient.call(
+ WalletApiOperation.CheckPeerPushDebit,
+ {
+ amount: "TESTKUDOS:200" as AmountString,
+ },
+ );
+
+ t.assertAmountEquals(checkResp0.amountEffective, "TESTKUDOS:200");
+
+ const resp = await w1.walletClient.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "Hello World 🥺",
+ amount: "TESTKUDOS:200" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ console.log(resp);
+
+ const peerPushReadyCond = w1.walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready &&
+ x.transactionId === resp.transactionId,
+ );
+
+ await peerPushReadyCond;
+
+ const txDetails = await w1.walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: resp.transactionId,
+ },
+ );
+ t.assertDeepEqual(txDetails.type, TransactionType.PeerPushDebit);
+ t.assertTrue(!!txDetails.talerUri);
+
+ const checkResp = await w2.walletClient.call(
+ WalletApiOperation.PreparePeerPushCredit,
+ {
+ talerUri: txDetails.talerUri,
+ },
+ );
+
+ console.log(checkResp);
+
+ const acceptResp = await w2.walletClient.call(
+ WalletApiOperation.ConfirmPeerPushCredit,
+ {
+ transactionId: checkResp.transactionId,
+ },
+ );
+
+ console.log(acceptResp);
+
+ await w2.walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ const txn1 = await w1.walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ const txn2 = await w2.walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log(`txn1: ${j2s(txn1)}`);
+ console.log(`txn2: ${j2s(txn2)}`);
+}
+
+runPeerPushLargeTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-peer-repair.ts b/packages/taler-harness/src/integrationtests/test-peer-repair.ts
new file mode 100644
index 000000000..22d3fe7ad
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-repair.ts
@@ -0,0 +1,213 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 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,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import * as fs from "node:fs";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+export async function runPeerRepairTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bankClient, exchange } = await createSimpleTestkudosEnvironmentV3(t);
+
+ let allW1Notifications: WalletNotification[] = [];
+ let allW2Notifications: WalletNotification[] = [];
+
+ let w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ handleNotification(wn) {
+ allW2Notifications.push(wn);
+ },
+ });
+
+ // Withdraw digital cash into the wallet.
+ let wallet1 = w1.walletClient;
+ const wallet2 = w2.walletClient;
+
+ const withdrawalDoneCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Done &&
+ x.transactionId.startsWith("txn:withdrawal:"),
+ );
+
+ await withdrawViaBankV3(t, {
+ walletClient: wallet1,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:5",
+ });
+
+ await withdrawalDoneCond;
+ const w1DbPath = w1.walletService.dbPath;
+ const w1DbCopyPath = w1.walletService.dbPath + ".copy";
+ fs.copyFileSync(w1DbPath, w1DbCopyPath);
+
+ const purse_expiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const resp1 = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:3" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ const peerPushDebitReady1Cond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp1.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPushDebitReady1Cond;
+
+ const txDetails = await wallet1.call(WalletApiOperation.GetTransactionById, {
+ transactionId: resp1.transactionId,
+ });
+ t.assertDeepEqual(txDetails.type, TransactionType.PeerPushDebit);
+ t.assertTrue(!!txDetails.talerUri);
+
+ const resp2 = await wallet2.client.call(
+ WalletApiOperation.PreparePeerPushCredit,
+ {
+ talerUri: txDetails.talerUri,
+ },
+ );
+
+ const peerPushCreditDone1Cond = wallet2.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ const peerPushDebitDone1Cond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp1.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ await wallet2.client.call(WalletApiOperation.ConfirmPeerPushCredit, {
+ transactionId: resp2.transactionId,
+ });
+
+ await peerPushCreditDone1Cond;
+ await peerPushDebitDone1Cond;
+
+ w1.walletClient.remoteWallet?.close();
+ await w1.walletService.stop();
+
+ fs.copyFileSync(w1DbCopyPath, w1DbPath);
+
+ console.log(`copied back to ${w1DbPath}`);
+
+ w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ wallet1 = w1.walletClient;
+
+ console.log("attempting peer-push-debit, should fail.");
+
+ const initResp2 = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:3" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ const peerPushDebitFailingCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === initResp2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.errorInfo != null,
+ );
+
+ console.log(`waiting for error on ${initResp2.transactionId}`);
+
+ await peerPushDebitFailingCond;
+
+ console.log("reached error");
+
+ // Now withdraw so we have enough coins to re-select
+
+ const withdraw2Res = await withdrawViaBankV3(t, {
+ walletClient: wallet1,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:5",
+ });
+
+ await withdraw2Res.withdrawalFinishedCond;
+
+ const peerPushDebitReady2Cond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === initResp2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPushDebitReady2Cond;
+}
+
+runPeerRepairTest.suites = ["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..d94c5985f
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.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 {
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ j2s,
+ NotificationType,
+ TalerCorebankApiClient,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ ExchangeService,
+ GlobalTestState,
+ WalletClient,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runPeerToPeerPullTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bankClient, exchange } = await createSimpleTestkudosEnvironmentV3(t);
+
+ let allW1Notifications: WalletNotification[] = [];
+ let allW2Notifications: WalletNotification[] = [];
+
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ persistent: true,
+ handleNotification(wn) {
+ allW2Notifications.push(wn);
+ },
+ });
+
+ // Withdraw digital cash into the wallet.
+ const wallet1 = w1.walletClient;
+ const wallet2 = w2.walletClient;
+
+ await checkNormalPeerPull(t, bankClient, exchange, wallet1, wallet2);
+
+ console.log(`w1 notifications: ${j2s(allW1Notifications)}`);
+
+ // Check that we don't have an excessive number of notifications.
+ t.assertTrue(allW1Notifications.length <= 60);
+
+ await checkAbortedPeerPull(t, bankClient, exchange, wallet1, wallet2);
+}
+
+async function checkNormalPeerPull(
+ t: GlobalTestState,
+ bankClient: TalerCorebankApiClient,
+ exchange: ExchangeService,
+ wallet1: WalletClient,
+ wallet2: WalletClient,
+): Promise<void> {
+ const withdrawRes = await withdrawViaBankV3(t, {
+ walletClient: wallet2,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawRes.withdrawalFinishedCond;
+
+ const purseExpiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const resp = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+
+ const peerPullCreditReadyCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPullCreditReadyCond;
+
+ const creditTx = await wallet1.call(WalletApiOperation.GetTransactionById, {
+ transactionId: resp.transactionId,
+ });
+
+ t.assertDeepEqual(creditTx.type, TransactionType.PeerPullCredit);
+ t.assertTrue(!!creditTx.talerUri);
+
+ const checkResp = await wallet2.client.call(
+ WalletApiOperation.PreparePeerPullDebit,
+ {
+ talerUri: creditTx.talerUri,
+ },
+ );
+
+ console.log(`checkResp: ${j2s(checkResp)}`);
+
+ const peerPullCreditDoneCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ const peerPullDebitDoneCond = wallet2.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === checkResp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ await wallet2.client.call(WalletApiOperation.ConfirmPeerPullDebit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ await peerPullCreditDoneCond;
+ await peerPullDebitDoneCond;
+
+ 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)}`);
+}
+
+async function checkAbortedPeerPull(
+ t: GlobalTestState,
+ bankClient: TalerCorebankApiClient,
+ exchange: ExchangeService,
+ wallet1: WalletClient,
+ wallet2: WalletClient,
+): Promise<void> {
+ const withdrawRes = await withdrawViaBankV3(t, {
+ walletClient: wallet2,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawRes.withdrawalFinishedCond;
+
+ const purseExpiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const resp = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+
+ const peerPullCreditReadyCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPullCreditReadyCond;
+
+ const creditTx = await wallet1.call(WalletApiOperation.GetTransactionById, {
+ transactionId: resp.transactionId,
+ });
+
+ t.assertDeepEqual(creditTx.type, TransactionType.PeerPullCredit);
+ t.assertTrue(!!creditTx.talerUri);
+
+ const checkResp = await wallet2.client.call(
+ WalletApiOperation.PreparePeerPullDebit,
+ {
+ talerUri: creditTx.talerUri,
+ },
+ );
+
+ console.log(`checkResp: ${j2s(checkResp)}`);
+
+ const peerPullCreditAbortedCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Aborted,
+ );
+
+ const peerPullDebitAbortedCond = wallet2.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === checkResp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Aborted,
+ );
+
+ await wallet1.call(WalletApiOperation.AbortTransaction, {
+ transactionId: resp.transactionId,
+ });
+
+ await wallet2.client.call(WalletApiOperation.ConfirmPeerPullDebit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ console.log(`waiting for ${resp.transactionId} to go to state aborted`);
+ console.log("checkpoint: before-aborted-wait");
+ await peerPullCreditAbortedCond;
+ console.log("checkpoint: after-credit-aborted-wait");
+ await peerPullDebitAbortedCond;
+ console.log("checkpoint: after-debit-aborted-wait");
+ console.log("checkpoint: after-aborted-wait");
+
+ 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..e38b690ab
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-to-peer-push.ts
@@ -0,0 +1,264 @@
+/*
+ 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,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WalletNotification,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run a test for basic peer-push payments.
+ */
+export async function runPeerToPeerPushTest(t: GlobalTestState) {
+ const { bankClient, exchange } = await createSimpleTestkudosEnvironmentV3(t);
+
+ let allW1Notifications: WalletNotification[] = [];
+ let allW2Notifications: WalletNotification[] = [];
+
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ handleNotification(wn) {
+ allW2Notifications.push(wn);
+ },
+ });
+
+ // Withdraw digital cash into the wallet.
+
+ const withdrawRes = await withdrawViaBankV3(t, {
+ walletClient: w1.walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawRes.withdrawalFinishedCond;
+
+ const purse_expiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const checkResp0 = await w1.walletClient.call(
+ WalletApiOperation.CheckPeerPushDebit,
+ {
+ amount: "TESTKUDOS:5" as AmountString,
+ },
+ );
+
+ t.assertAmountEquals(checkResp0.amountEffective, "TESTKUDOS:5.49");
+
+ {
+ const resp = await w1.walletClient.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "Hello World 😁😇",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ console.log(resp);
+ }
+
+ {
+ const bal = await w1.walletClient.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals(bal.balances[0].pendingOutgoing, "TESTKUDOS:5.49");
+ }
+
+ await w1.walletClient.call(WalletApiOperation.TestingWaitRefreshesFinal, {});
+
+ const resp = await w1.walletClient.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "Hello World 🥺",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ console.log(resp);
+
+ const peerPushReadyCond = w1.walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready &&
+ x.transactionId === resp.transactionId,
+ );
+
+ await peerPushReadyCond;
+
+ const txDetails = await w1.walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: resp.transactionId,
+ },
+ );
+ t.assertDeepEqual(txDetails.type, TransactionType.PeerPushDebit);
+ t.assertTrue(!!txDetails.talerUri);
+
+ const checkResp = await w2.walletClient.call(
+ WalletApiOperation.PreparePeerPushCredit,
+ {
+ talerUri: txDetails.talerUri,
+ },
+ );
+
+ console.log(checkResp);
+
+ const acceptResp = await w2.walletClient.call(
+ WalletApiOperation.ConfirmPeerPushCredit,
+ {
+ transactionId: checkResp.transactionId,
+ },
+ );
+
+ console.log(acceptResp);
+
+ const txn1 = await w1.walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ const txn2 = await w2.walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log(`txn1: ${j2s(txn1)}`);
+ console.log(`txn2: ${j2s(txn2)}`);
+
+ // We expect insufficient balance here!
+ const ex1 = await t.assertThrowsTalerErrorAsync(async () => {
+ await w1.walletClient.call(WalletApiOperation.InitiatePeerPushDebit, {
+ partialContractTerms: {
+ summary: "(this will fail)",
+ amount: "TESTKUDOS:15" as AmountString,
+ purse_expiration,
+ },
+ });
+ });
+
+ console.log("got expected exception detail", j2s(ex1.errorDetail));
+
+ const initiateResp2 = await w1.walletClient.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "second tx, will expire",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ const peerPushReadyCond2 = w1.walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready &&
+ x.transactionId === initiateResp2.transactionId,
+ );
+
+ await peerPushReadyCond2;
+
+ const txDetails3 = await w1.walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: initiateResp2.transactionId,
+ },
+ );
+ t.assertDeepEqual(txDetails3.type, TransactionType.PeerPushDebit);
+ t.assertTrue(!!txDetails3.talerUri);
+
+ await w2.walletClient.call(WalletApiOperation.PreparePeerPushCredit, {
+ talerUri: txDetails3.talerUri,
+ });
+
+ const timetravelOffsetMs = Duration.toMilliseconds(
+ Duration.fromSpec({ days: 5 }),
+ );
+
+ console.log("stopping exchange to apply time-travel");
+
+ await exchange.stop();
+ exchange.setTimetravel(timetravelOffsetMs);
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ console.log("running expire");
+ await exchange.runExpireOnce();
+ console.log("done running expire");
+
+ console.log("purse should now be expired");
+
+ await w1.walletClient.call(WalletApiOperation.TestingSetTimetravel, {
+ offsetMs: timetravelOffsetMs,
+ });
+
+ await w2.walletClient.call(WalletApiOperation.TestingSetTimetravel, {
+ offsetMs: timetravelOffsetMs,
+ });
+
+ await w1.walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ await w2.walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ const txDetails2 = await w1.walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: initiateResp2.transactionId,
+ },
+ );
+
+ console.log(`tx details 2: ${j2s(txDetails2)}`);
+}
+
+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..5fcfa066a
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-refund-auto.ts
@@ -0,0 +1,113 @@
+/*
+ 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, MerchantApiClient } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runRefundAutoTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ // Set up order.
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ auto_refund: {
+ d_us: 3000 * 1000,
+ },
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 5 }),
+ ),
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: r1.transactionId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ const ref = await merchantClient.giveRefund({
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log(ref);
+
+ // The wallet should now automatically pick up the refund.
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const transactions = await walletClient.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..ac3a5aebe
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-refund-gone.ts
@@ -0,0 +1,139 @@
+/*
+ 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,
+ MerchantApiClient,
+ TransactionMajorState,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ applyTimeTravelV2,
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Test wallet behavior when a refund expires before the wallet
+ * can claim it.
+ */
+export async function runRefundGoneTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ // Set up order.
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({
+ minutes: 10,
+ }),
+ ),
+ ),
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: r1.transactionId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ console.log(orderStatus);
+
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ hours: 1 })),
+ { exchange, merchant, walletClient: walletClient },
+ );
+ await exchange.stopAggregator();
+ await exchange.runAggregatorOnce();
+
+ const ref = await merchantClient.giveRefund({
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log(ref);
+
+ await walletClient.call(WalletApiOperation.StartRefundQuery, {
+ transactionId: r1.transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ let r = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(JSON.stringify(r, undefined, 2));
+
+ const r3 = await walletClient.call(WalletApiOperation.GetTransactions, {});
+ console.log(JSON.stringify(r3, undefined, 2));
+
+ const refundTx = r3.transactions[2];
+
+ t.assertDeepEqual(refundTx.type, TransactionType.Refund);
+ t.assertDeepEqual(refundTx.txState.major, TransactionMajorState.Failed);
+}
+
+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..2f78d7e91
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-refund-incremental.ts
@@ -0,0 +1,203 @@
+/*
+ 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 {
+ Amounts,
+ Duration,
+ MerchantApiClient,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, delayMs } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runRefundIncrementalTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ // Set up order.
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:10",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 5 }),
+ ),
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ proposalId: r1.proposalId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ let ref = await merchantClient.giveRefund({
+ amount: "TESTKUDOS:2.5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log("first refund increase response", ref);
+
+ {
+ let wr = await walletClient.call(WalletApiOperation.StartRefundQuery, {
+ transactionId: r1.transactionId,
+ });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ console.log(wr);
+ const txs = await walletClient.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 merchantClient.giveRefund({
+ 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 merchantClient.giveRefund({
+ amount: "TESTKUDOS:10",
+ instance: "default",
+ justification: "bar",
+ orderId: orderResp.order_id,
+ });
+
+ console.log("third refund increase response", ref);
+
+ {
+ let wr = await walletClient.call(WalletApiOperation.StartRefundQuery, {
+ transactionId: r1.transactionId,
+ });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ console.log(wr);
+ }
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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 walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(JSON.stringify(bal, undefined, 2));
+
+ {
+ const txs = await walletClient.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"]);
+
+ 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..4b197a01f
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-refund.ts
@@ -0,0 +1,149 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020-2024 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,
+ MerchantApiClient,
+ NotificationType,
+ TransactionMajorState,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+export async function runRefundTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ walletClient: wallet,
+ bankClient,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Withdraw digital cash into the wallet.
+
+ const withdrawalRes = await withdrawViaBankV3(t, {
+ walletClient: wallet,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawalRes.withdrawalFinishedCond;
+
+ // Set up order.
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 5 }),
+ ),
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ 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, {
+ transactionId: r1.transactionId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ {
+ const tx = await wallet.client.call(WalletApiOperation.GetTransactionById, {
+ transactionId: r1.transactionId,
+ });
+
+ t.assertTrue(
+ tx.type === TransactionType.Payment && tx.refundPending === undefined,
+ );
+ }
+
+ const ref = await merchantClient.giveRefund({
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log(ref);
+
+ {
+ const refundFinishedCond = wallet.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === r1.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+ await wallet.client.call(WalletApiOperation.StartRefundQuery, {
+ transactionId: r1.transactionId,
+ });
+ await refundFinishedCond;
+ }
+
+ {
+ 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));
+ }
+
+ {
+ const tx = await wallet.client.call(WalletApiOperation.GetTransactionById, {
+ transactionId: r1.transactionId,
+ });
+
+ console.log(j2s(tx));
+
+ t.assertTrue(
+ tx.type === TransactionType.Payment && tx.refundPending === undefined,
+ );
+ }
+}
+
+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..65aa86f98
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-revocation.ts
@@ -0,0 +1,267 @@
+/*
+ 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 {
+ TalerCorebankApiClient,
+ TalerMerchantApi,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ WalletCli,
+ WalletClient,
+ delayMs,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ SimpleTestEnvironmentNg3,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+async function revokeAllWalletCoins(req: {
+ walletClient: WalletClient;
+ exchange: ExchangeService;
+ merchant: MerchantService;
+}): Promise<void> {
+ const { walletClient, exchange, merchant } = req;
+ const coinDump = await walletClient.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<SimpleTestEnvironmentNg3> {
+ 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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL(
+ "accounts/exchange/taler-wire-gateway/",
+ bank.baseUrl,
+ ).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ const { walletService, walletClient } = await createWalletDaemonWithClient(
+ t,
+ {
+ name: "default",
+ },
+ );
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ walletService,
+ bankClient,
+ exchangeBankAccount: {
+ accountName: "",
+ accountPassword: "",
+ accountPaytoUri: "",
+ wireGatewayApiBaseUrl: "",
+ },
+ };
+}
+
+/**
+ * Basic time travel test.
+ */
+export async function runRevocationTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createTestEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:15",
+ });
+ await wres.withdrawalFinishedCond;
+
+ console.log("revoking first time");
+ await revokeAllWalletCoins({ walletClient, exchange, merchant });
+
+ // FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565
+ // is implemented.
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log("wallet balance", bal);
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:10",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ } satisfies TalerMerchantApi.Order;
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+
+ await walletClient.call(WalletApiOperation.ClearDb, {});
+
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:15",
+ });
+
+ const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {});
+ console.log(coinDump);
+ const coinPubList = coinDump.coins.map((x) => x.coin_pub);
+ await walletClient.call(WalletApiOperation.ForceRefresh, {
+ refreshCoinSpecs: coinPubList.map((x) => ({ coinPub: x })),
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ console.log("revoking second time");
+ await revokeAllWalletCoins({ walletClient, exchange, merchant });
+
+ // FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565
+ // is implemented.
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ {
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log("wallet balance", bal);
+ }
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+}
+
+runRevocationTest.timeoutMs = 120000;
+runRevocationTest.suites = ["wallet"];
+runRevocationTest.experimental = true;
diff --git a/packages/taler-harness/src/integrationtests/test-simple-payment.ts b/packages/taler-harness/src/integrationtests/test-simple-payment.ts
new file mode 100644
index 000000000..846b8c8e1
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-simple-payment.ts
@@ -0,0 +1,59 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 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 {
+ withdrawViaBankV2,
+ makeTestPaymentV2,
+ useSharedTestkudosEnvironment,
+} from "../harness/helpers.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runSimplePaymentTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await useSharedTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ } satisfies TalerMerchantApi.Order;
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runSimplePaymentTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-stored-backups.ts b/packages/taler-harness/src/integrationtests/test-stored-backups.ts
new file mode 100644
index 000000000..732ac0aed
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-stored-backups.ts
@@ -0,0 +1,113 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 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 {
+ withdrawViaBankV2,
+ makeTestPaymentV2,
+ useSharedTestkudosEnvironment,
+} from "../harness/helpers.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+/**
+ * Test stored backup wallet-core API.
+ */
+export async function runStoredBackupsTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await useSharedTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres;
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const sb1Resp = await walletClient.call(
+ WalletApiOperation.CreateStoredBackup,
+ {},
+ );
+ const sbList = await walletClient.call(
+ WalletApiOperation.ListStoredBackups,
+ {},
+ );
+ t.assertTrue(sbList.storedBackups.length === 1);
+ t.assertTrue(sbList.storedBackups[0].name === sb1Resp.name);
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ } satisfies TalerMerchantApi.Order;
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txn1 = await walletClient.call(WalletApiOperation.GetTransactions, {});
+ t.assertDeepEqual(txn1.transactions.length, 2);
+
+ // Recover from the stored backup now.
+
+ const sb2Resp = await walletClient.call(
+ WalletApiOperation.CreateStoredBackup,
+ {},
+ );
+
+ console.log("recovering backup");
+
+ await walletClient.call(WalletApiOperation.RecoverStoredBackup, {
+ name: sb1Resp.name,
+ });
+
+ console.log("first recovery done");
+
+ // Recovery went well, now we can delete the backup
+ // of the old database we stored before importing.
+ {
+ const sbl1 = await walletClient.call(
+ WalletApiOperation.ListStoredBackups,
+ {},
+ );
+ t.assertTrue(sbl1.storedBackups.length === 2);
+
+ await walletClient.call(WalletApiOperation.DeleteStoredBackup, {
+ name: sb1Resp.name,
+ });
+ const sbl2 = await walletClient.call(
+ WalletApiOperation.ListStoredBackups,
+ {},
+ );
+ t.assertTrue(sbl2.storedBackups.length === 1);
+ }
+
+ const txn2 = await walletClient.call(WalletApiOperation.GetTransactions, {});
+ // We only have the withdrawal after restoring
+ t.assertDeepEqual(txn2.transactions.length, 1);
+}
+
+runStoredBackupsTest.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..e6c84b75d
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts
@@ -0,0 +1,234 @@
+/*
+ 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,
+ MerchantApiClient,
+ NotificationType,
+ PreparePayResultType,
+ TalerCorebankApiClient,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ applyTimeTravelV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * 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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ exchange.addCoinConfigList(makeNoFeeCoinConfig("TESTKUDOS"));
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ });
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:15",
+ });
+ await wres.withdrawalFinishedCond;
+
+ const exchangeUpdated1Cond = walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.ExchangeStateTransition &&
+ x.exchangeBaseUrl === exchange.baseUrl,
+ );
+
+ // Travel into the future, the deposit expiration is two years
+ // into the future.
+ console.log("applying first time travel");
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ days: 400 })),
+ {
+ walletClient,
+ exchange,
+ merchant,
+ },
+ );
+
+ // The time travel should cause exchanges to update.
+ await exchangeUpdated1Cond;
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const wres2 = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres2.withdrawalFinishedCond;
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const exchangeUpdated2Cond = walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.ExchangeStateTransition &&
+ x.exchangeBaseUrl === exchange.baseUrl,
+ );
+
+ // Travel into the future, the deposit expiration is two years
+ // into the future.
+ console.log("applying second time travel");
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ years: 2, months: 6 })),
+ {
+ walletClient,
+ exchange,
+ merchant,
+ },
+ );
+
+ // The time travel should cause exchanges to update.
+ await exchangeUpdated2Cond;
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // 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.
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ fulfillment_url: "http://example.com",
+ summary: "foo",
+ amount: "TESTKUDOS:30",
+ },
+ });
+
+ const orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ instance: "default",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const r = await walletClient.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ console.log(r);
+
+ t.assertTrue(r.status === PreparePayResultType.PaymentPossible);
+
+ const cpr = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: r.transactionId,
+ });
+
+ 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..4ee3a86e9
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-timetravel-withdraw.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 {
+ Duration,
+ TransactionMajorState,
+ TransactionType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Basic time travel test.
+ */
+export async function runTimetravelWithdrawTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres1 = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:15",
+ });
+ await wres1.withdrawalFinishedCond;
+
+ // 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(Duration.toMilliseconds(timetravelDuration));
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+ await exchange.keyup();
+
+ await merchant.stop();
+ merchant.setTimetravel(Duration.toMilliseconds(timetravelDuration));
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ console.log("starting withdrawal via bank");
+
+ // This should fail, as the wallet didn't time travel yet.
+ const wres2 = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ console.log("starting withdrawal done");
+
+ // Check that transactions are correct for the failed withdrawal
+ {
+ const transactions = await walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {
+ sort: "stable-ascending",
+ },
+ );
+ console.log(j2s(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.txState.major === TransactionMajorState.Pending);
+ }
+
+ // Now we also let the wallet time travel
+
+ walletClient.call(WalletApiOperation.TestingSetTimetravel, {
+ offsetMs: Duration.toMilliseconds(timetravelDuration),
+ });
+
+ // The wallet should do denomination re-selection and succeed
+
+ await wres2.withdrawalFinishedCond;
+}
+
+runTimetravelWithdrawTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-tos-format.ts b/packages/taler-harness/src/integrationtests/test-tos-format.ts
new file mode 100644
index 000000000..e6087af9d
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-tos-format.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 { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+} from "../harness/helpers.js";
+import * as fs from "fs";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runTermOfServiceFormatTest(t: GlobalTestState) {
+ // Set up test environment
+ const tosDir = t.testDir + `/tos/`;
+ const langs = ["es", "en", "de"]
+
+ langs.forEach(l => {
+ const langDir = tosDir + l + "/"
+ fs.mkdirSync(langDir, { recursive: true });
+ fs.writeFileSync(langDir + "v1.txt", "text content");
+ fs.writeFileSync(langDir + "v1.md", "markdown content");
+ fs.writeFileSync(langDir + "v1.html", "html content");
+ });
+
+ const { walletClient, exchange, } =
+ await createSimpleTestkudosEnvironmentV2(t, undefined, {
+ additionalExchangeConfig: (ex) => {
+ ex.changeConfig((cfg) => {
+ cfg.setString("exchange", "terms_etag", "v1")
+ cfg.setString("exchange", "terms_dir", tosDir)
+ })
+ }
+ });
+
+
+ {
+ const tos = await walletClient.client.call(WalletApiOperation.GetExchangeTos, {
+ exchangeBaseUrl: exchange.baseUrl,
+ })
+
+ t.assertDeepEqual(tos.content, "text content");
+ }
+
+ {
+ const tos = await walletClient.client.call(WalletApiOperation.GetExchangeTos, {
+ exchangeBaseUrl: exchange.baseUrl,
+ acceptedFormat: ["text/html"]
+ })
+
+ t.assertDeepEqual(tos.content, "html content");
+ }
+
+ {
+ const tos = await walletClient.client.call(WalletApiOperation.GetExchangeTos, {
+ exchangeBaseUrl: exchange.baseUrl,
+ acceptedFormat: ["text/markdown"]
+ })
+
+ t.assertDeepEqual(tos.content, "markdown content");
+ }
+
+ {
+ const tos = await walletClient.client.call(WalletApiOperation.GetExchangeTos, {
+ exchangeBaseUrl: exchange.baseUrl,
+ acceptedFormat: ["text/markdown", "text/html"]
+ })
+
+ // prefer markdown since its the first one in the list
+ t.assertDeepEqual(tos.content, "markdown content");
+ }
+
+ {
+ const tos = await walletClient.client.call(WalletApiOperation.GetExchangeTos, {
+ exchangeBaseUrl: exchange.baseUrl,
+ acceptedFormat: ["text/pdf", "text/html"]
+ })
+
+ // there is no pdf so fallback in html
+ t.assertDeepEqual(tos.content, "html content");
+ }
+}
+
+runTermOfServiceFormatTest.suites = ["wallet"];
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..94d43e195
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-backup-basic.ts
@@ -0,0 +1,189 @@
+/*
+ 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 {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} 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, walletClient, bankClient, exchange } =
+ await createSimpleTestkudosEnvironmentV3(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 walletClient.call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: sync.baseUrl,
+ activate: false,
+ name: sync.baseUrl,
+ });
+
+ {
+ const bi = await walletClient.call(WalletApiOperation.GetBackupInfo, {});
+ t.assertDeepEqual(bi.providers[0].active, false);
+ }
+
+ await walletClient.call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: sync.baseUrl,
+ activate: true,
+ name: sync.baseUrl,
+ });
+
+ {
+ const bi = await walletClient.call(WalletApiOperation.GetBackupInfo, {});
+ t.assertDeepEqual(bi.providers[0].active, true);
+ }
+
+ await walletClient.call(WalletApiOperation.RunBackupCycle, {});
+
+ {
+ const bi = await walletClient.call(WalletApiOperation.GetBackupInfo, {});
+ console.log(bi);
+ t.assertDeepEqual(
+ bi.providers[0].paymentStatus.type,
+ "insufficient-balance",
+ );
+ }
+
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:10",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ await walletClient.call(WalletApiOperation.RunBackupCycle, {});
+
+ {
+ const bi = await walletClient.call(WalletApiOperation.GetBackupInfo, {});
+ console.log(bi);
+ }
+
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:5",
+ });
+
+ await walletClient.call(WalletApiOperation.RunBackupCycle, {});
+
+ {
+ const bi = await walletClient.call(WalletApiOperation.GetBackupInfo, {});
+ console.log(bi);
+ }
+
+ const backupRecovery = await walletClient.call(
+ WalletApiOperation.ExportBackupRecovery,
+ {},
+ );
+
+ const txs = await walletClient.call(WalletApiOperation.GetTransactions, {});
+ console.log(`backed up transactions ${j2s(txs)}`);
+
+ const { walletClient: walletClient2 } = await createWalletDaemonWithClient(
+ t,
+ {
+ name: "w2",
+ },
+ );
+
+ // Check that the second wallet is a fresh wallet.
+ {
+ const bal = await walletClient2.call(WalletApiOperation.GetBalances, {});
+ t.assertTrue(bal.balances.length === 0);
+ }
+
+ await walletClient2.call(WalletApiOperation.ImportBackupRecovery, {
+ recovery: backupRecovery,
+ });
+
+ await walletClient2.call(WalletApiOperation.RunBackupCycle, {});
+
+ // Check that now the old balance is available!
+ {
+ const bal = await walletClient2.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 walletClient2.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(`restored transactions ${j2s(txs)}`);
+ const bal1 = await walletClient2.call(WalletApiOperation.GetBalances, {});
+
+ t.assertAmountEquals(bal1.balances[0].available, "TESTKUDOS:14.1");
+
+ await withdrawViaBankV3(t, {
+ walletClient: walletClient2,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:10",
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await walletClient2.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ const txs2 = await walletClient2.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(`tx after withdraw after restore ${j2s(txs2)}`);
+
+ const bal2 = await walletClient2.call(WalletApiOperation.GetBalances, {});
+
+ t.assertAmountEquals(bal2.balances[0].available, "TESTKUDOS:23.82");
+ }
+}
+
+runWalletBackupBasicTest.suites = ["wallet", "wallet-backup"];
+// See https://bugs.taler.net/n/7598
+runWalletBackupBasicTest.experimental = true;
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..abcd71a3b
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-backup-doublespend.ts
@@ -0,0 +1,183 @@
+/*
+ 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 { MerchantApiClient, PreparePayResultType } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+import { SyncService } from "../harness/sync.js";
+
+export async function runWalletBackupDoublespendTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { commonDb, merchant, walletClient, bankClient, exchange } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ 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 walletClient.call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: sync.baseUrl,
+ activate: true,
+ name: sync.baseUrl,
+ });
+
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:10",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ await walletClient.call(WalletApiOperation.RunBackupCycle, {});
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ await walletClient.call(WalletApiOperation.RunBackupCycle, {});
+
+ const backupRecovery = await walletClient.call(
+ WalletApiOperation.ExportBackupRecovery,
+ {},
+ );
+
+ const { walletClient: walletClientTwo } = await createWalletDaemonWithClient(
+ t,
+ { name: "default" },
+ );
+
+ await walletClientTwo.call(WalletApiOperation.ImportBackupRecovery, {
+ recovery: backupRecovery,
+ });
+
+ await walletClientTwo.call(WalletApiOperation.RunBackupCycle, {});
+
+ console.log(
+ "wallet1 balance before spend:",
+ await walletClient.call(WalletApiOperation.GetBalances, {}),
+ );
+
+ await makeTestPaymentV2(t, {
+ merchant,
+ walletClient,
+ order: {
+ summary: "foo",
+ amount: "TESTKUDOS:7",
+ },
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ console.log(
+ "wallet1 balance after spend:",
+ await walletClient.call(WalletApiOperation.GetBalances, {}),
+ );
+
+ {
+ console.log(
+ "wallet2 balance:",
+ await walletClientTwo.call(WalletApiOperation.GetBalances, {}),
+ );
+ }
+
+ // Now we double-spend with the second wallet
+
+ {
+ const instance = "default";
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ amount: "TESTKUDOS:8",
+ summary: "bla",
+ fulfillment_url: "taler://fulfillment-success",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ {
+ console.log(
+ "wallet2 balance before preparePay:",
+ await walletClientTwo.call(WalletApiOperation.GetBalances, {}),
+ );
+ }
+
+ const preparePayResult = await walletClientTwo.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertDeepEqual(
+ preparePayResult.status,
+ PreparePayResultType.PaymentPossible,
+ );
+
+ const res = await walletClientTwo.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ console.log(res);
+
+ // FIXME: wait for a notification that indicates insufficient funds!
+
+ await withdrawViaBankV3(t, {
+ walletClient: walletClientTwo,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:50",
+ });
+
+ const bal = await walletClientTwo.call(WalletApiOperation.GetBalances, {});
+ console.log("bal", bal);
+
+ await walletClientTwo.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+}
+
+runWalletBackupDoublespendTest.suites = ["wallet", "wallet-backup"];
+// See https://bugs.taler.net/n/7598
+runWalletBackupDoublespendTest.experimental = true;
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-balance-notifications.ts b/packages/taler-harness/src/integrationtests/test-wallet-balance-notifications.ts
new file mode 100644
index 000000000..1586e2a72
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-balance-notifications.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 {
+ NotificationType,
+ TalerCorebankApiClient,
+ TransactionMajorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV3 } from "../harness/helpers.js";
+
+/**
+ * Test behavior when an order is deleted while the wallet is paying for it.
+ */
+export async function runWalletBalanceNotificationsTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, walletService } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const amount = "TESTKUDOS:20";
+
+ const user = await bankClient.createRandomBankUser();
+ bankClient.setAuth({
+ username: user.username,
+ password: user.password,
+ });
+
+ const wop = await bankClient.createWithdrawalOperation(user.username, amount);
+
+ // Hand it to the wallet
+
+ await walletClient.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+
+ // Withdraw (AKA select)
+
+ const balanceChangedNotif1 = walletClient.waitForNotificationCond(
+ (x) => x.type === NotificationType.BalanceChange,
+ );
+
+ const acceptRes = await walletClient.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ t.logStep("wait-balance-notif-1");
+ await balanceChangedNotif1;
+ t.logStep("done-wait-balance-notif-1");
+
+ const withdrawalFinishedCond = walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Done &&
+ x.transactionId === acceptRes.transactionId,
+ );
+
+ // Confirm it
+
+ await bankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ await withdrawalFinishedCond;
+
+ // Second withdrawal!
+ {
+ const wop2 = await bankClient.createWithdrawalOperation(
+ user.username,
+ "TESTKUDOS:5",
+ );
+
+ await walletClient.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop2.taler_withdraw_uri,
+ });
+
+ const acceptRes = await walletClient.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop2.taler_withdraw_uri,
+ },
+ );
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:19.53");
+ t.assertAmountEquals(bal.balances[0].pendingIncoming, "TESTKUDOS:4.85");
+
+ await walletService.stop();
+ }
+}
+
+runWalletBalanceNotificationsTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-balance-zero.ts b/packages/taler-harness/src/integrationtests/test-wallet-balance-zero.ts
new file mode 100644
index 000000000..01cf7c159
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-balance-zero.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 { j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Related bugs:
+ * https://bugs.taler.net/n/8323
+ */
+export async function runWalletBalanceZeroTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfig = makeNoFeeCoinConfig("TESTKUDOS");
+ console.log(`coin config ${j2s(coinConfig)}`);
+ const { merchant, walletClient, exchange, bankClient } =
+ await createSimpleTestkudosEnvironmentV3(t, coinConfig);
+
+ const wres = await withdrawViaBankV3(t, {
+ amount: "TESTKUDOS:10",
+ bankClient,
+ exchange,
+ walletClient,
+ });
+ await wres.withdrawalFinishedCond;
+
+ await makeTestPaymentV2(t, {
+ walletClient,
+ merchant,
+ order: {
+ summary: "Hello, World!",
+ amount: "TESTKUDOS:10",
+ },
+ });
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(`${j2s(bal)}`);
+ t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:0");
+}
+
+runWalletBalanceZeroTest.suites = ["wallet"];
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..c37a6e482
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-balance.ts
@@ -0,0 +1,130 @@
+/*
+ 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 {
+ Amounts,
+ Duration,
+ MerchantApiClient,
+ MerchantContractTerms,
+ PreparePayResultType,
+ TalerMerchantApi,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Test 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 { merchant, walletClient, exchange, bankClient } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ await merchant.addInstanceWithWireAccount({
+ id: "myinst",
+ name: "My Instance",
+ paytoUris: ["payto://void/foo"],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const merchantClient = new MerchantApiClient(
+ merchant.makeInstanceBaseUrl("myinst"),
+ );
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ console.log("withdrawal finished");
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ console.log("creating order");
+
+ const orderResp = await merchantClient.createOrder({
+ order,
+ });
+
+ console.log("created order with merchant");
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ console.log("queried order at merchant");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.InsufficientBalance,
+ );
+
+ t.assertDeepEqual(
+ preparePayResult.status,
+ PreparePayResultType.InsufficientBalance,
+ );
+
+ t.assertTrue(
+ Amounts.isNonZero(
+ preparePayResult.balanceDetails.balanceReceiverAcceptable,
+ ),
+ );
+
+ t.assertTrue(
+ Amounts.isZero(preparePayResult.balanceDetails.balanceReceiverDepositable),
+ );
+
+ console.log("waiting for transactions to finalize");
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runWalletBalanceTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts
new file mode 100644
index 000000000..66f985114
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts
@@ -0,0 +1,150 @@
+/*
+ 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 {
+ AmountString,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState, generateRandomPayto } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+/**
+ * Run test for refreshe after a payment.
+ */
+export async function runWalletBlockedDepositTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+ {
+ ...coinCommon,
+ name: "n5",
+ value: "TESTKUDOS:5",
+ },
+ ];
+
+ const { bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t, coinConfigList);
+
+ // Withdraw digital cash into the wallet.
+
+ const { walletClient: w1 } = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ await withdrawViaBankV3(t, {
+ walletClient: w1,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Prevent the wallet from doing refreshes by injecting a 5xx
+ // status for all refresh requests.
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/start-block-refresh",
+ });
+
+ await makeTestPaymentV2(t, {
+ merchant,
+ walletClient: w1,
+ order: {
+ summary: "test",
+ amount: "TESTKUDOS:2",
+ },
+ });
+
+ const userPayto = generateRandomPayto("foo");
+
+ const bal = await w1.call(WalletApiOperation.GetBalances, {});
+ console.log(`balance: ${j2s(bal)}`);
+
+ const balDet = await w1.call(WalletApiOperation.GetBalanceDetail, {
+ currency: "TESTKUDOS",
+ });
+ console.log(`balance details: ${j2s(balDet)}`);
+
+ const depositCheckResp = await w1.call(WalletApiOperation.PrepareDeposit, {
+ amount: "TESTKUDOS:18" as AmountString,
+ depositPaytoUri: userPayto,
+ });
+
+ console.log(`check resp: ${j2s(depositCheckResp)}`);
+
+ const depositCreateResp = await w1.call(
+ WalletApiOperation.CreateDepositGroup,
+ {
+ amount: "TESTKUDOS:18" as AmountString,
+ depositPaytoUri: userPayto,
+ },
+ );
+
+ console.log(`create resp: ${j2s(depositCreateResp)}`);
+
+ const depositTrackCond = w1.waitForNotificationCond((n) => {
+ return (
+ n.type === NotificationType.TransactionStateTransition &&
+ n.transactionId === depositCreateResp.transactionId &&
+ n.newTxState.major === TransactionMajorState.Pending &&
+ n.newTxState.minor === TransactionMinorState.Track
+ );
+ });
+
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/stop-block-refresh",
+ });
+
+ await depositTrackCond;
+}
+
+runWalletBlockedDepositTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts
new file mode 100644
index 000000000..004de87c8
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts
@@ -0,0 +1,142 @@
+/*
+ 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 {
+ MerchantApiClient,
+ PreparePayResultType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+ {
+ ...coinCommon,
+ name: "n5",
+ value: "TESTKUDOS:5",
+ },
+];
+
+/**
+ * Run test for paying a merchant with balance locked behind a pending refresh.
+ */
+export async function runWalletBlockedPayMerchantTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange, merchant } = await createSimpleTestkudosEnvironmentV2(
+ t,
+ coinConfigList,
+ );
+
+ // Withdraw digital cash into the wallet.
+
+ const { walletClient: w1 } = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ await withdrawViaBankV2(t, {
+ walletClient: w1,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Prevent the wallet from doing refreshes by injecting a 5xx
+ // status for all refresh requests.
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/start-block-refresh",
+ });
+
+ // Do a payment that causes a refresh.
+ await makeTestPaymentV2(t, {
+ merchant,
+ walletClient: w1,
+ order: {
+ summary: "test",
+ amount: "TESTKUDOS:2",
+ },
+ });
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "My Payment",
+ amount: "TESTKUDOS:18",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await w1.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ console.log(`prepare pay result: ${j2s(preparePayResult)}`);
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/stop-block-refresh",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runWalletBlockedPayMerchantTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts
new file mode 100644
index 000000000..36a6fea05
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts
@@ -0,0 +1,177 @@
+/*
+ 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,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+/**
+ * Run test for a peer push payment with balance locked behind a pending refresh.
+ */
+export async function runWalletBlockedPayPeerPullTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+ {
+ ...coinCommon,
+ name: "n5",
+ value: "TESTKUDOS:5",
+ },
+ ];
+
+ const { bank, exchange, merchant } = await createSimpleTestkudosEnvironmentV2(
+ t,
+ coinConfigList,
+ );
+
+ // Withdraw digital cash into the wallet.
+
+ const { walletClient: w1 } = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ const { walletClient: w2 } = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ persistent: true,
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ await withdrawViaBankV2(t, {
+ walletClient: w1,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Prevent the wallet from doing refreshes by injecting a 5xx
+ // status for all refresh requests.
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/start-block-refresh",
+ });
+
+ // Do a payment that causes a refresh.
+ await makeTestPaymentV2(t, {
+ merchant,
+ walletClient: w1,
+ order: {
+ summary: "test",
+ amount: "TESTKUDOS:2",
+ },
+ });
+
+ await w2.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const pullCreditReadyCond = w2.waitForNotificationCond((n) => {
+ return (
+ n.type === NotificationType.TransactionStateTransition &&
+ n.transactionId.startsWith("txn:peer-pull-credit:") &&
+ n.newTxState.major === TransactionMajorState.Pending &&
+ n.newTxState.minor === TransactionMinorState.Ready
+ );
+ });
+
+ const initResp = await w2.call(WalletApiOperation.InitiatePeerPullCredit, {
+ partialContractTerms: {
+ summary: "hi!",
+ amount: "TESTKUDOS:18" as AmountString,
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ ),
+ },
+ });
+
+ await pullCreditReadyCond;
+
+ const initTx = await w2.call(WalletApiOperation.GetTransactionById, {
+ transactionId: initResp.transactionId,
+ });
+
+ t.assertDeepEqual(initTx.type, TransactionType.PeerPullCredit);
+ t.assertTrue(!!initTx.talerUri);
+
+ const checkResp = await w1.call(WalletApiOperation.PreparePeerPullDebit, {
+ talerUri: initTx.talerUri,
+ });
+
+ console.log(`check resp ${j2s(checkResp)}`);
+
+ const confirmResp = await w1.call(WalletApiOperation.ConfirmPeerPullDebit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ console.log(`confirm resp ${j2s(confirmResp)}`);
+
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/stop-block-refresh",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runWalletBlockedPayPeerPullTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-push.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-push.ts
new file mode 100644
index 000000000..7427f2b07
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-push.ts
@@ -0,0 +1,149 @@
+/*
+ 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,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+/**
+ * Run test for a peer push payment with balance locked behind a pending refresh.
+ */
+export async function runWalletBlockedPayPeerPushTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+ {
+ ...coinCommon,
+ name: "n5",
+ value: "TESTKUDOS:5",
+ },
+ ];
+
+ const { bank, exchange, merchant } = await createSimpleTestkudosEnvironmentV2(
+ t,
+ coinConfigList,
+ );
+
+ // Withdraw digital cash into the wallet.
+
+ const { walletClient: w1 } = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ await withdrawViaBankV2(t, {
+ walletClient: w1,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Prevent the wallet from doing refreshes by injecting a 5xx
+ // status for all refresh requests.
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/start-block-refresh",
+ });
+
+ // Do a payment that causes a refresh.
+ await makeTestPaymentV2(t, {
+ merchant,
+ walletClient: w1,
+ order: {
+ summary: "test",
+ amount: "TESTKUDOS:2",
+ },
+ });
+
+ const checkResp = await w1.call(WalletApiOperation.CheckPeerPushDebit, {
+ amount: "TESTKUDOS:18" as AmountString,
+ });
+
+ console.log(`check resp ${j2s(checkResp)}`);
+
+ const readyCond = w1.waitForNotificationCond((n) => {
+ return (
+ n.type === NotificationType.TransactionStateTransition &&
+ n.transactionId.startsWith("txn:peer-push-debit:") &&
+ n.newTxState.major === TransactionMajorState.Pending &&
+ n.newTxState.minor === TransactionMinorState.Ready
+ );
+ });
+
+ const confirmResp = await w1.call(WalletApiOperation.InitiatePeerPushDebit, {
+ partialContractTerms: {
+ summary: "hi!",
+ amount: "TESTKUDOS:18" as AmountString,
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ ),
+ },
+ });
+
+ console.log(`confirm resp ${j2s(confirmResp)}`);
+
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/stop-block-refresh",
+ });
+
+ await readyCond;
+}
+
+runWalletBlockedPayPeerPushTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts b/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts
new file mode 100644
index 000000000..bcd7de74b
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts
@@ -0,0 +1,101 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 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 { AmountString } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ MerchantService,
+ WalletCli,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+
+/**
+ * Test that run-until-done of taler-wallet-cli terminates.
+ */
+export async function runWalletCliTerminationTest(t: GlobalTestState) {
+ const db = await setupDb(t);
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+
+ 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();
+
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ const wallet = new WalletCli(t, "wallet");
+
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:20" as AmountString,
+ });
+
+ await wallet.runUntilDone();
+}
+
+runWalletCliTerminationTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-config.ts b/packages/taler-harness/src/integrationtests/test-wallet-config.ts
new file mode 100644
index 000000000..461574031
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-config.ts
@@ -0,0 +1,67 @@
+/*
+ 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 { createWalletDaemonWithClient } from "../harness/helpers.js";
+
+export async function runWalletConfigTest(t: GlobalTestState) {
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ config: {
+ builtin: {
+ exchanges: [],
+ },
+ },
+ });
+
+ const exchangesResp1 = await w1.walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesResp1.exchanges.length, 0);
+
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ config: {
+ builtin: {
+ exchanges: [
+ {
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ currencyHint: "KUDOS",
+ },
+ {
+ exchangeBaseUrl: "https://exchange.test.taler.net/",
+ currencyHint: "TESTKUDOS",
+ },
+ ],
+ },
+ },
+ });
+
+ const exchangesResp2 = await w2.walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesResp2.exchanges.length, 2);
+}
+
+runWalletConfigTest.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..6c2006636
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-cryptoworker.ts
@@ -0,0 +1,42 @@
+/*
+ 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, WalletCli } from "../harness/harness.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..a089d99b5
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-dbless.ts
@@ -0,0 +1,160 @@
+/*
+ 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 {
+ AmountString,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import {
+ applyRunConfigDefaults,
+ CryptoDispatcher,
+ SynchronousCryptoWorkerFactoryPlain,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ checkReserve,
+ depositCoin,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ refreshCoin,
+ topupReserveWithBank,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core/dbless";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } 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 createSimpleTestkudosEnvironmentV2(t);
+
+ const http = harnessHttpLib;
+ const cryptiDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptiDisp.cryptoApi;
+
+ try {
+ // Withdraw digital cash into the wallet.
+
+ const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http);
+
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+
+ let reserveUrl = new URL(
+ `reserves/${reserveKeyPair.pub}`,
+ exchange.baseUrl,
+ );
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+ const longpollReq = http.fetch(reserveUrl.href, {
+ method: "GET",
+ });
+
+ await topupReserveWithBank({
+ amount: "TESTKUDOS:10" as AmountString,
+ http,
+ reservePub: reserveKeyPair.pub,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeInfo,
+ });
+
+ console.log("waiting for longpoll request");
+ const resp = await longpollReq;
+ console.log(`got response, status ${resp.status}`);
+
+ console.log(exchangeInfo);
+
+ await checkReserve(http, exchange.baseUrl, reserveKeyPair.pub);
+
+ const defaultConfig = applyRunConfigDefaults();
+
+ const d1 = findDenomOrThrow(exchangeInfo, "TESTKUDOS:8" as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ });
+
+ const coin = await withdrawCoin({
+ http,
+ cryptoApi,
+ reserveKeyPair: {
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ },
+ denom: d1,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const wireSalt = encodeCrock(getRandomBytes(16));
+ const merchantPub = encodeCrock(getRandomBytes(32));
+ const contractTermsHash = encodeCrock(getRandomBytes(64));
+
+ await depositCoin({
+ contractTermsHash,
+ merchantPub,
+ wireSalt,
+ amount: "TESTKUDOS:4" as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+
+ // Idempotency
+ await depositCoin({
+ contractTermsHash,
+ merchantPub,
+ wireSalt,
+ amount: "TESTKUDOS:4" as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+
+ const refreshDenoms = [
+ findDenomOrThrow(exchangeInfo, "TESTKUDOS:1" as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ }),
+ findDenomOrThrow(exchangeInfo, "TESTKUDOS:1" as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ }),
+ ];
+
+ 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-wallet-dd48.ts b/packages/taler-harness/src/integrationtests/test-wallet-dd48.ts
new file mode 100644
index 000000000..ba2b2670c
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-dd48.ts
@@ -0,0 +1,206 @@
+/*
+ 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 {
+ ExchangeEntryStatus,
+ NotificationType,
+ TalerCorebankApiClient,
+ TalerError,
+ TalerErrorCode,
+ WalletNotification,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ WalletClient,
+ WalletService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import { withdrawViaBankV3 } from "../harness/helpers.js";
+
+/**
+ * Test for DD48 notifications.
+ */
+export async function runWalletDd48Test(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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ const walletService = new WalletService(t, {
+ name: "wallet",
+ useInMemoryDb: true,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const allNotifications: WalletNotification[] = [];
+
+ const walletClient = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ onNotification(n) {
+ console.log("got notification", n);
+ allNotifications.push(n);
+ },
+ });
+ await walletClient.connect();
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ skipDefaults: true,
+ },
+ },
+ });
+
+ await walletClient.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ {
+ const exchangeEntry = await walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+
+ t.assertDeepEqual(
+ exchangeEntry.exchangeEntryStatus,
+ ExchangeEntryStatus.Ephemeral,
+ );
+
+ const resources = await walletClient.call(
+ WalletApiOperation.GetExchangeResources,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+ t.assertDeepEqual(resources.hasResources, false);
+ }
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ amount: "TESTKUDOS:20",
+ bankClient,
+ exchange,
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const exchangeEntry = await walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+
+ t.assertDeepEqual(
+ exchangeEntry.exchangeEntryStatus,
+ ExchangeEntryStatus.Used,
+ );
+
+ t.assertTrue(
+ !!allNotifications.find(
+ (x) =>
+ x.type === NotificationType.ExchangeStateTransition &&
+ x.oldExchangeState == null &&
+ x.newExchangeState.exchangeEntryStatus ===
+ ExchangeEntryStatus.Ephemeral,
+ ),
+ );
+
+ console.log(j2s(allNotifications));
+
+ const delErr = await t.assertThrowsAsync(async () => {
+ await walletClient.call(WalletApiOperation.DeleteExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+ });
+
+ t.assertTrue(delErr instanceof TalerError);
+ t.assertDeepEqual(
+ delErr.errorDetail.code,
+ TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED,
+ );
+
+ await walletClient.call(WalletApiOperation.DeleteExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ purge: true,
+ });
+}
+
+runWalletDd48Test.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts b/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts
new file mode 100644
index 000000000..b9d028efd
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts
@@ -0,0 +1,176 @@
+/*
+ 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, Logger, NotificationType, TalerCorebankApiClient, j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ applyTimeTravelV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+const logger = new Logger("test-exchange-timetravel.ts");
+
+/**
+ * Test how the wallet handles an expired denomination.
+ */
+export async function runWalletDenomExpireTest(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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ exchange.addCoinConfigList(makeNoFeeCoinConfig("TESTKUDOS"));
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ console.log("merchant started, configuring instances");
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
+ });
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:15",
+ });
+ await wres.withdrawalFinishedCond;
+
+ const denomLossCond = walletClient.waitForNotificationCond((n) => {
+ return (
+ n.type === NotificationType.TransactionStateTransition &&
+ n.transactionId.startsWith("txn:denom-loss:")
+ );
+ });
+
+ // Travel into the future, the deposit expiration is two years
+ // into the future.
+ console.log("applying first time travel");
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ days: 800 })),
+ {
+ walletClient,
+ exchange,
+ merchant,
+ },
+ );
+
+ t.logStep("before-wait-denom-loss");
+
+ // Should be detected automatically, as exchange entry is surely outdated.
+ await denomLossCond;
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(`balances: ${j2s(bal)}`);
+
+ const txns = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ includeRefreshes: true,
+ });
+ console.log(`transactions: ${j2s(txns)}`);
+}
+
+runWalletDenomExpireTest.suites = ["exchange"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-dev-experiments.ts b/packages/taler-harness/src/integrationtests/test-wallet-dev-experiments.ts
new file mode 100644
index 000000000..1f1187d80
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-dev-experiments.ts
@@ -0,0 +1,48 @@
+/*
+ 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 { createWalletDaemonWithClient } from "../harness/helpers.js";
+
+export async function runWalletDevExperimentsTest(t: GlobalTestState) {
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ await w1.walletClient.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/insert-pending-refresh",
+ });
+
+ const txnResp = await w1.walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {
+ includeRefreshes: true,
+ },
+ );
+
+ t.assertTrue(txnResp.transactions.length > 0);
+}
+
+runWalletDevExperimentsTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts
new file mode 100644
index 000000000..b36e6ef61
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts
@@ -0,0 +1,166 @@
+/*
+ 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 {
+ AmountString,
+ ExchangeUpdateStatus,
+ NotificationType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Test how the wallet reacts when an exchange unexpectedly updates
+ * properties like the master public key.
+ */
+export async function runWalletExchangeUpdateTest(
+ t: GlobalTestState,
+): Promise<void> {
+ // Set up test environment
+
+ const db = await setupDb(t);
+ const db2 = await setupDb(t, {
+ nameSuffix: "two",
+ });
+
+ const bank = await FakebankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchangeOne = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ // Danger: The second exchange has the same port!
+ // That's because we want it to have the same base URL,
+ // and we'll only start on of them at a time.
+ const exchangeTwo = ExchangeService.create(t, {
+ name: "testexchange-2",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db2.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+
+ await exchangeOne.addBankAccount("1", exchangeBankAccount);
+ await exchangeTwo.addBankAccount("1", exchangeBankAccount);
+
+ // Same anyway.
+ bank.setSuggestedExchange(exchangeOne, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ exchangeOne.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS")));
+ exchangeTwo.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS")));
+
+ // Only start first exchange.
+ await exchangeOne.start();
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wallet",
+ persistent: true,
+ });
+
+ // Since the default exchanges can change, we start the wallet in tests
+ // with no built-in defaults. Thus the list of exchanges is empty here.
+ const exchangesListResult = await walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesListResult.exchanges.length, 0);
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: exchangeOne,
+ amount: "TESTKUDOS:10",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ await exchangeOne.stop();
+
+ console.log("starting second exchange");
+ await exchangeTwo.start();
+
+ console.log("updating exchange entry");
+
+ await t.assertThrowsAsync(async () => {
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchangeOne.baseUrl,
+ force: true,
+ });
+ });
+
+ const exchangeEntry = await walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ {
+ exchangeBaseUrl: exchangeOne.baseUrl,
+ },
+ );
+
+ console.log(`exchange entry: ${j2s(exchangeEntry)}`);
+
+ await t.assertThrowsAsync(async () => {
+ await walletClient.call(WalletApiOperation.GetWithdrawalDetailsForAmount, {
+ amount: "TESTKUDOS:10" as AmountString,
+ exchangeBaseUrl: exchangeOne.baseUrl,
+ });
+ });
+
+ const exchangeAvailableCond = walletClient.waitForNotificationCond((n) => {
+ console.log(`got notif ${j2s(n)}`);
+ return (
+ n.type === NotificationType.ExchangeStateTransition &&
+ n.newExchangeState.exchangeUpdateStatus === ExchangeUpdateStatus.Ready
+ );
+ });
+
+ await exchangeTwo.stop();
+
+ console.log("starting first exchange");
+ await exchangeOne.start();
+
+ await exchangeAvailableCond;
+}
+
+runWalletExchangeUpdateTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-gendb.ts b/packages/taler-harness/src/integrationtests/test-wallet-gendb.ts
new file mode 100644
index 000000000..778f36432
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-gendb.ts
@@ -0,0 +1,111 @@
+/*
+ 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,
+ AmountString,
+ Duration,
+ NotificationType,
+ TalerMerchantApi,
+ TransactionMajorState,
+ TransactionMinorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Test that creates various transactions and exports the resulting
+ * database. Used to generate a database export file for DB compatibility
+ * testing.
+ */
+export async function runWalletGenDbTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:50",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:10",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const purseExpiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const peerPullIniResp = await walletClient.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+
+ const peerPullCreditReadyCond = walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === peerPullIniResp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPullCreditReadyCond;
+
+ const checkResp = await walletClient.call(
+ WalletApiOperation.PreparePeerPullDebit,
+ {
+ talerUri: peerPullIniResp.talerUri,
+ },
+ );
+
+ await walletClient.call(WalletApiOperation.ConfirmPeerPullDebit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runWalletGenDbTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts b/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts
new file mode 100644
index 000000000..ac1244446
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts
@@ -0,0 +1,166 @@
+/*
+ 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 {
+ AmountString,
+ Duration,
+ PaymentInsufficientBalanceDetails,
+ TalerErrorCode,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ MerchantService,
+ WalletClient,
+ WalletService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import { withdrawViaBankV2 } from "../harness/helpers.js";
+
+export async function runWalletInsufficientBalanceTest(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",
+ );
+ exchangeBankAccount.skipWireFeeCreation = true;
+ 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const walletService = new WalletService(t, {
+ name: "wallet",
+ useInMemoryDb: true,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const allNotifications: WalletNotification[] = [];
+
+ const walletClient = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ onNotification(n) {
+ console.log("got notification", n);
+ allNotifications.push(n);
+ },
+ });
+ await walletClient.connect();
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ skipDefaults: true,
+ },
+ },
+ });
+
+ const wres = await withdrawViaBankV2(t, {
+ amount: "TESTKUDOS:10",
+ bank,
+ exchange,
+ walletClient,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const exc = await t.assertThrowsTalerErrorAsync(async () => {
+ await walletClient.call(WalletApiOperation.PrepareDeposit, {
+ amount: "TESTKUDOS:5" as AmountString,
+ depositPaytoUri: "payto://x-taler-bank/localhost/foobar",
+ });
+ });
+ t.assertDeepEqual(
+ exc.errorDetail.code,
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ );
+ const insufficientBalanceDetails: PaymentInsufficientBalanceDetails =
+ exc.errorDetail.insufficientBalanceDetails;
+
+ t.assertAmountEquals(
+ insufficientBalanceDetails.balanceAvailable,
+ "TESTKUDOS:9.72",
+ );
+ t.assertAmountEquals(
+ insufficientBalanceDetails.balanceExchangeDepositable,
+ "TESTKUDOS:0",
+ );
+}
+
+runWalletInsufficientBalanceTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-notifications.ts b/packages/taler-harness/src/integrationtests/test-wallet-notifications.ts
new file mode 100644
index 000000000..5088c8228
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-notifications.ts
@@ -0,0 +1,195 @@
+/*
+ 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 {
+ TalerCorebankApiClient,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+} 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,
+ WalletClient,
+ WalletService,
+ generateRandomPayto,
+ generateRandomTestIban,
+ setupDb,
+} from "../harness/harness.js";
+
+/**
+ * Test for wallet-core notifications.
+ */
+export async function runWalletNotificationsTest(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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [
+ `payto://iban/SANDBOXX/${generateRandomTestIban(label)}?receiver-name=${label}`,
+ ],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ console.log("setup done!");
+
+ const walletService = new WalletService(t, {
+ name: "wallet",
+ useInMemoryDb: true,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const walletClient = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ onNotification(n) {
+ console.log("got notification", n);
+ },
+ });
+ await walletClient.connect();
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ skipDefaults: true,
+ }
+ }
+ });
+
+ const user = await bankClient.createRandomBankUser();
+ bankClient.setAuth(user);
+ const wop = await bankClient.createWithdrawalOperation(
+ user.username,
+ "TESTKUDOS:20",
+ );
+
+ // Hand it to the wallet
+
+ await walletClient.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ // Withdraw (AKA select)
+
+ const acceptRes = await walletClient.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ const withdrawalFinishedReceivedPromise =
+ walletClient.waitForNotificationCond((x) => {
+ return (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Done &&
+ x.transactionId === acceptRes.transactionId
+ );
+ });
+
+ // Confirm it
+
+ await bankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ await withdrawalFinishedReceivedPromise;
+}
+
+runWalletNotificationsTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-observability.ts b/packages/taler-harness/src/integrationtests/test-wallet-observability.ts
new file mode 100644
index 000000000..55a60cb76
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-observability.ts
@@ -0,0 +1,141 @@
+/*
+ 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 { NotificationType, TalerCorebankApiClient, WalletNotification } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ WalletClient,
+ WalletService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import { withdrawViaBankV3 } from "../harness/helpers.js";
+
+export async function runWalletObservabilityTest(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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ const walletService = new WalletService(t, {
+ name: "wallet",
+ useInMemoryDb: true,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const allNotifications: WalletNotification[] = [];
+
+ const walletClient = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ onNotification(n) {
+ console.log("got notification", n);
+ allNotifications.push(n);
+ },
+ });
+ await walletClient.connect();
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ skipDefaults: true,
+ emitObservabilityEvents: true,
+ },
+ },
+ });
+
+ const wres = await withdrawViaBankV3(t, {
+ amount: "TESTKUDOS:10",
+ bankClient,
+ exchange,
+ walletClient,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const requestObsEvents = allNotifications.filter(
+ (x) => x.type === NotificationType.RequestObservabilityEvent,
+ );
+ const taskObsEvents = allNotifications.filter(
+ (x) => x.type === NotificationType.TaskObservabilityEvent,
+ );
+
+ console.log(`req events: ${requestObsEvents.length}`);
+ console.log(`task events: ${taskObsEvents.length}`);
+
+ t.assertTrue(requestObsEvents.length > 30);
+ t.assertTrue(taskObsEvents.length > 30);
+}
+
+runWalletObservabilityTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts b/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts
new file mode 100644
index 000000000..0f1efd35e
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts
@@ -0,0 +1,107 @@
+/*
+ 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 { AmountString } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+/**
+ * Run test for refreshe after a payment.
+ */
+export async function runWalletRefreshErrorsTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+ {
+ ...coinCommon,
+ name: "n5",
+ value: "TESTKUDOS:5",
+ },
+ ];
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t, coinConfigList);
+
+ const wres = await withdrawViaBankV2(t, {
+ amount: "TESTKUDOS:5",
+ bank,
+ exchange,
+ walletClient,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const backupResp = await walletClient.call(
+ WalletApiOperation.CreateStoredBackup,
+ {},
+ );
+
+ const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {});
+
+ t.assertDeepEqual(coinDump.coins.length, 1);
+
+ await walletClient.call(WalletApiOperation.ForceRefresh, {
+ refreshCoinSpecs: [
+ {
+ coinPub: coinDump.coins[0].coin_pub,
+ amount: "TESTKUDOS:3" as AmountString,
+ },
+ ],
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ await walletClient.call(WalletApiOperation.RecoverStoredBackup, {
+ name: backupResp.name,
+ });
+
+ await walletClient.call(WalletApiOperation.ForceRefresh, {
+ refreshCoinSpecs: [
+ {
+ coinPub: coinDump.coins[0].coin_pub,
+ amount: "TESTKUDOS:3" as AmountString,
+ },
+ ],
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runWalletRefreshErrorsTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-refresh.ts b/packages/taler-harness/src/integrationtests/test-wallet-refresh.ts
new file mode 100644
index 000000000..93fe94270
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-refresh.ts
@@ -0,0 +1,201 @@
+/*
+ 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 {
+ AmountString,
+ NotificationType,
+ TalerMerchantApi,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import {
+ WalletApiOperation,
+ parseTransactionIdentifier,
+} from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, generateRandomPayto } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for refreshe after a payment.
+ */
+export async function runWalletRefreshTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txns = await walletClient.call(WalletApiOperation.GetTransactions, {
+ includeRefreshes: true,
+ });
+
+ console.log(j2s(txns));
+
+ t.assertDeepEqual(txns.transactions.length, 3);
+
+ const refreshListTx = txns.transactions.find(
+ (x) => x.type === TransactionType.Refresh,
+ );
+
+ t.assertTrue(!!refreshListTx);
+
+ const refreshTx = await walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: refreshListTx.transactionId,
+ },
+ );
+
+ t.assertDeepEqual(refreshTx.type, TransactionType.Refresh);
+
+ // Now we test a pending refresh operation.
+ {
+ await exchange.stop();
+
+ const refreshCreatedCond = walletClient.waitForNotificationCond((x) => {
+ if (
+ x.type === NotificationType.TransactionStateTransition &&
+ parseTransactionIdentifier(x.transactionId)?.tag ===
+ TransactionType.Refresh
+ ) {
+ return true;
+ }
+ return false;
+ });
+
+ const refreshDoneCond = walletClient.waitForNotificationCond((x) => {
+ if (
+ x.type === NotificationType.TransactionStateTransition &&
+ parseTransactionIdentifier(x.transactionId)?.tag ===
+ TransactionType.Refresh &&
+ x.newTxState.major === TransactionMajorState.Done
+ ) {
+ return true;
+ }
+ return false;
+ });
+
+ const depositGroupResult = await walletClient.client.call(
+ WalletApiOperation.CreateDepositGroup,
+ {
+ amount: "TESTKUDOS:10.5" as AmountString,
+ depositPaytoUri: generateRandomPayto("foo"),
+ },
+ );
+
+ await refreshCreatedCond;
+
+ // Here, the refresh operation should be in a pending state.
+
+ const bal1 = await walletClient.call(WalletApiOperation.GetBalances, {});
+
+ await exchange.start();
+
+ await refreshDoneCond;
+
+ const bal2 = await walletClient.call(WalletApiOperation.GetBalances, {});
+
+ // The refresh operation completing should not change the available balance,
+ // as we're accounting pending refreshes towards the available (but not material!) balance.
+ t.assertAmountEquals(
+ bal1.balances[0].available,
+ bal2.balances[0].available,
+ );
+ }
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ // Test failing a refresh transaction
+ {
+ await exchange.stop();
+
+ let refreshTransactionId: TransactionIdStr | undefined = undefined;
+
+ const refreshCreatedCond = walletClient.waitForNotificationCond((x) => {
+ if (
+ x.type === NotificationType.TransactionStateTransition &&
+ parseTransactionIdentifier(x.transactionId)?.tag ===
+ TransactionType.Refresh
+ ) {
+ refreshTransactionId = x.transactionId as TransactionIdStr;
+ return true;
+ }
+ return false;
+ });
+
+ const depositGroupResult = await walletClient.client.call(
+ WalletApiOperation.CreateDepositGroup,
+ {
+ amount: "TESTKUDOS:10.5" as AmountString,
+ depositPaytoUri: generateRandomPayto("foo"),
+ },
+ );
+
+ await refreshCreatedCond;
+
+ t.assertTrue(!!refreshTransactionId);
+
+ await walletClient.call(WalletApiOperation.FailTransaction, {
+ transactionId: refreshTransactionId,
+ });
+
+ const txn = await walletClient.call(WalletApiOperation.GetTransactionById, {
+ transactionId: refreshTransactionId,
+ });
+
+ t.assertDeepEqual(txn.type, TransactionType.Refresh);
+ t.assertDeepEqual(txn.txState.major, TransactionMajorState.Failed);
+
+ t.assertTrue(!!refreshTransactionId);
+ }
+}
+
+runWalletRefreshTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-wirefees.ts b/packages/taler-harness/src/integrationtests/test-wallet-wirefees.ts
new file mode 100644
index 000000000..c5a0fd363
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-wirefees.ts
@@ -0,0 +1,210 @@
+/*
+ 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,
+ MerchantApiClient,
+ PreparePayResultType,
+ TalerCorebankApiClient,
+ TalerMerchantApi,
+ TransactionMajorState,
+} 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,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/helpers.js";
+
+/**
+ * Test payment where the exchange charges wire fees.
+ */
+export async function runWalletWirefeesTest(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,
+ // Ridiculously high wire fees!
+ overrideWireFee: "TESTKUDOS:5",
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL(
+ "accounts/exchange/taler-wire-gateway/",
+ bank.baseUrl,
+ ).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ { name: "wallet", persistent: true },
+ );
+
+ console.log("setup done!");
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const order: TalerMerchantApi.Order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:1",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ //max_wire_fee: "TESTKUDOS:0.1",
+ max_fee: "TESTKUDOS:0.1",
+ };
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const orderResp = await merchantClient.createOrder({
+ order,
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ console.log(`amountEffective: ${preparePayResult.amountEffective}`);
+
+ t.assertAmountEquals(preparePayResult.amountEffective, "TESTKUDOS:6.4");
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const payTxn = await walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: preparePayResult.transactionId,
+ },
+ );
+
+ t.assertTrue(payTxn.txState.major === TransactionMajorState.Done);
+}
+
+runWalletWirefeesTest.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..001081532
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallettesting.ts
@@ -0,0 +1,247 @@
+/*
+ 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 { AmountString, Amounts, CoinStatus } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ setupDb,
+ generateRandomPayto,
+ FakebankService,
+} from "../harness/harness.js";
+import {
+ SimpleTestEnvironmentNg,
+ createWalletDaemonWithClient,
+} 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<SimpleTestEnvironmentNg> {
+ 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();
+
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ console.log("setup done!");
+
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ {
+ name: "w1",
+ },
+ );
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ walletService,
+ bank,
+ exchangeBankAccount,
+ };
+}
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWallettestingTest(t: GlobalTestState) {
+ const { walletClient, bank, exchange, merchant } =
+ await createMyEnvironment(t);
+
+ await walletClient.call(WalletApiOperation.RunIntegrationTest, {
+ amountToSpend: "TESTKUDOS:5" as AmountString,
+ amountToWithdraw: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ merchantAuthToken: merchantAuthToken,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ });
+
+ let txns = await walletClient.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",
+ ]);
+
+ await walletClient.call(WalletApiOperation.ClearDb, {});
+
+ await walletClient.call(WalletApiOperation.WithdrawTestBalance, {
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ await walletClient.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:5" as AmountString,
+ merchantAuthToken: merchantAuthToken,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ summary: "foo",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ txns = await walletClient.call(WalletApiOperation.GetTransactions, {});
+ console.log(JSON.stringify(txns, undefined, 2));
+ txTypes = txns.transactions.map((x) => x.type);
+
+ t.assertDeepEqual(txTypes, ["withdrawal", "payment"]);
+
+ await walletClient.call(WalletApiOperation.ClearDb, {});
+
+ await walletClient.call(WalletApiOperation.WithdrawTestBalance, {
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const coinDump = await walletClient.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 walletClient.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 walletClient.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:5" as AmountString,
+ merchantAuthToken: merchantAuthToken,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ summary: "foo",
+ });
+ });
+
+ console.log("unsuspending coin");
+
+ await walletClient.call(WalletApiOperation.SetCoinSuspended, {
+ coinPub: susp,
+ suspended: false,
+ });
+
+ await walletClient.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:5" as AmountString,
+ merchantAuthToken: merchantAuthToken,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ summary: "foo",
+ });
+
+ await walletClient.call(WalletApiOperation.ClearDb, {});
+ await walletClient.call(WalletApiOperation.RunIntegrationTestV2, {
+ amountToSpend: "TESTKUDOS:5" as AmountString,
+ amountToWithdraw: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ merchantAuthToken: merchantAuthToken,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ });
+}
+
+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..b87e67a68
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-abort-bank.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 { TalerErrorCode } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV3 } from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWithdrawalAbortBankTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Create a withdrawal operation
+
+ const user = await bankClient.createRandomBankUser();
+ bankClient.setAuth(user);
+ const wop = await bankClient.createWithdrawalOperation(
+ user.username,
+ "TESTKUDOS:10",
+ );
+
+ // Hand it to the wallet
+
+ await walletClient.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Abort it
+
+ await bankClient.abortWithdrawalOperationV2(user.username, wop);
+
+ // Withdraw
+
+ // Difference:
+ // -> with euFin, the wallet selects
+ // -> with PyBank, the wallet stops _before_
+ //
+ // WHY ?!
+ //
+ const e = await t.assertThrowsTalerErrorAsync(async () => {
+ await walletClient.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-amount.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-amount.ts
new file mode 100644
index 000000000..cd6a1e325
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-amount.ts
@@ -0,0 +1,94 @@
+/*
+ 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 {
+ AmountString,
+ Logger,
+ WireGatewayApiClient,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV3 } from "../harness/helpers.js";
+
+const logger = new Logger("test-withdrawal-manual.ts");
+
+/**
+ * Check what happens when the withdrawal amount unexpectedly changes.
+ */
+export async function runWithdrawalAmountTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, exchangeBankAccount } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const wireGatewayApiClient = new WireGatewayApiClient(
+ exchangeBankAccount.wireGatewayApiBaseUrl,
+ {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ },
+ );
+
+ // Create a withdrawal operation
+
+ const user = await bankClient.createRandomBankUser();
+
+ await walletClient.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ logger.info("starting AcceptManualWithdrawal request");
+
+ const wres = await walletClient.call(
+ WalletApiOperation.AcceptManualWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10" as AmountString,
+ },
+ );
+
+ logger.info("AcceptManualWithdrawal finished");
+ logger.info(`result: ${j2s(wres)}`);
+
+ const reservePub: string = wres.reservePub;
+
+ await wireGatewayApiClient.adminAddIncoming({
+ amount: "TESTKUDOS:5",
+ debitAccountPayto: user.accountPaytoUri,
+ reservePub: reservePub,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Check balance
+
+ const balResp = await walletClient.call(WalletApiOperation.GetBalances, {});
+
+ // We managed to withdraw the actually transferred amount!
+ t.assertAmountEquals(balResp.balances[0].available, "TESTKUDOS:4.85");
+
+ await t.shutdown();
+}
+
+runWithdrawalAmountTest.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..a13095883
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts
@@ -0,0 +1,187 @@
+/*
+ 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 {
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WithdrawalType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV3 } from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Create a withdrawal operation
+ const user = await bankClient.createRandomBankUser();
+ bankClient.setAuth(user);
+ const wop = await bankClient.createWithdrawalOperation(
+ user.username,
+ "TESTKUDOS:10",
+ );
+
+ // Hand it to the wallet
+
+ const r1 = await walletClient.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ // Withdraw
+
+ const r2 = await walletClient.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ const withdrawalBankConfirmedCond = walletClient.waitForNotificationCond(
+ (x) => {
+ return (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === r2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.ExchangeWaitReserve
+ );
+ },
+ );
+
+ const withdrawalFinishedCond = walletClient.waitForNotificationCond((x) => {
+ return (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === r2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done
+ );
+ });
+
+ const withdrawalReserveReadyCond = walletClient.waitForNotificationCond(
+ (x) => {
+ return (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === r2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.WithdrawCoins
+ );
+ },
+ );
+
+ // Do it twice to check idempotency
+ const r3 = await walletClient.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ await exchange.stopWirewatch();
+
+ // Check status before withdrawal is confirmed by bank.
+ {
+ const txn = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log("transactions before confirmation:", j2s(txn));
+ const tx0 = txn.transactions[0];
+ t.assertTrue(tx0.type === TransactionType.Withdrawal);
+ t.assertTrue(
+ tx0.withdrawalDetails.type === WithdrawalType.TalerBankIntegrationApi,
+ );
+ t.assertTrue(tx0.withdrawalDetails.confirmed === false);
+ t.assertTrue(tx0.withdrawalDetails.reserveIsReady === false);
+ }
+
+ // Confirm it
+
+ await bankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ await withdrawalBankConfirmedCond;
+
+ // Check status after withdrawal is confirmed by bank,
+ // but before funds are wired to the exchange.
+ {
+ const txn = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log("transactions after confirmation:", j2s(txn));
+ const tx0 = txn.transactions[0];
+ t.assertTrue(tx0.type === TransactionType.Withdrawal);
+ t.assertTrue(
+ tx0.withdrawalDetails.type === WithdrawalType.TalerBankIntegrationApi,
+ );
+ t.assertTrue(tx0.withdrawalDetails.confirmed === true);
+ t.assertTrue(tx0.withdrawalDetails.reserveIsReady === false);
+ }
+
+ await exchange.startWirewatch();
+
+ await withdrawalReserveReadyCond;
+
+ // Check status after funds were wired.
+ {
+ const txn = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log("transactions after reserve ready:", j2s(txn));
+ const tx0 = txn.transactions[0];
+ t.assertTrue(tx0.type === TransactionType.Withdrawal);
+ t.assertTrue(
+ tx0.withdrawalDetails.type === WithdrawalType.TalerBankIntegrationApi,
+ );
+ t.assertTrue(tx0.withdrawalDetails.confirmed === true);
+ t.assertTrue(tx0.withdrawalDetails.reserveIsReady === true);
+ }
+
+ await withdrawalFinishedCond;
+
+ // Check balance
+
+ const balResp = await walletClient.client.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
+
+ const txn = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(`transactions: ${j2s(txn)}`);
+}
+
+runWithdrawalBankIntegratedTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts
new file mode 100644
index 000000000..615feafa7
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.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 {
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ Logger,
+ TalerBankConversionApi,
+ TalerCorebankApiClient,
+ TransactionType,
+ WireGatewayApiClient,
+ WithdrawalType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import * as http from "node:http";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import { createWalletDaemonWithClient } from "../harness/helpers.js";
+
+const logger = new Logger("test-withdrawal-conversion.ts");
+
+interface TestfakeConversionService {
+ stop: () => void;
+}
+
+function splitInTwoAt(s: string, separator: string): [string, string] {
+ const idx = s.indexOf(separator);
+ if (idx === -1) {
+ return [s, ""];
+ }
+ return [s.slice(0, idx), s.slice(idx + 1)];
+}
+
+/**
+ * Testfake for the kyc service that the exchange talks to.
+ */
+async function runTestfakeConversionService(): Promise<TestfakeConversionService> {
+ const server = http.createServer((req, res) => {
+ const requestUrl = req.url!;
+ logger.info(`kyc: got ${req.method} request, ${requestUrl}`);
+
+ const [path, query] = splitInTwoAt(requestUrl, "?");
+
+ const qp = new URLSearchParams(query);
+
+ if (path === "/config") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ version: "0:0:0",
+ name: "taler-conversion-info",
+ regional_currency: "FOO",
+ fiat_currency: "BAR",
+ regional_currency_specification: {
+ alt_unit_names: {},
+ name: "FOO",
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ },
+ fiat_currency_specification: {
+ alt_unit_names: {},
+ name: "BAR",
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ },
+ conversion_rate: {
+ cashin_fee: "A:1" as AmountString,
+ cashin_min_amount: "A:0.1" as AmountString,
+ cashin_ratio: "1",
+ cashin_rounding_mode: "zero",
+ cashin_tiny_amount: "A:1" as AmountString,
+ cashout_fee: "A:1" as AmountString,
+ cashout_min_amount: "A:0.1" as AmountString,
+ cashout_ratio: "1",
+ cashout_rounding_mode: "zero",
+ cashout_tiny_amount: "A:1" as AmountString,
+ },
+ } satisfies TalerBankConversionApi.IntegrationConfig),
+ );
+ } else if (path === "/cashin-rate") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ amount_debit: "FOO:123",
+ amount_credit: "BAR:123",
+ }),
+ );
+ } else {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ code: 1, message: "bad request" }));
+ }
+ });
+ await new Promise<void>((resolve, reject) => {
+ server.listen(8071, () => resolve());
+ });
+ return {
+ stop() {
+ server.close();
+ },
+ };
+}
+
+/**
+ * Test for currency conversion during manual withdrawal.
+ */
+export async function runWithdrawalConversionTest(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",
+ );
+ exchangeBankAccount.conversionUrl = "http://localhost:8071/";
+ await exchange.addBankAccount("1", exchangeBankAccount);
+
+ 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.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ { name: "wallet" },
+ );
+
+ await runTestfakeConversionService();
+
+ // Create a withdrawal operation
+
+ const bankAccessApiClient = new TalerCorebankApiClient(
+ bank.corebankApiBaseUrl,
+ );
+
+ const user = await bankAccessApiClient.createRandomBankUser();
+
+ await walletClient.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const infoRes = await walletClient.call(
+ WalletApiOperation.GetWithdrawalDetailsForAmount,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:20" as AmountString,
+ },
+ );
+
+ console.log(`withdrawal details: ${j2s(infoRes)}`);
+
+ const checkTransferAmount = infoRes.withdrawalAccountsList[0].transferAmount;
+ t.assertTrue(checkTransferAmount != null);
+ t.assertAmountEquals(checkTransferAmount, "FOO:123");
+
+ const tStart = AbsoluteTime.now();
+
+ logger.info("starting AcceptManualWithdrawal request");
+ // We expect this to return immediately.
+
+ const wres = await walletClient.call(
+ WalletApiOperation.AcceptManualWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10" as AmountString,
+ },
+ );
+
+ logger.info("AcceptManualWithdrawal finished");
+ logger.info(`result: ${j2s(wres)}`);
+
+ const acceptedTransferAmount = wres.withdrawalAccountsList[0].transferAmount;
+ t.assertTrue(acceptedTransferAmount != null);
+
+ t.assertAmountEquals(acceptedTransferAmount, "FOO:123");
+
+ const txInfo = await walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: wres.transactionId,
+ },
+ );
+
+ t.assertDeepEqual(txInfo.type, TransactionType.Withdrawal);
+ t.assertDeepEqual(
+ txInfo.withdrawalDetails.type,
+ WithdrawalType.ManualTransfer,
+ );
+ t.assertTrue(!!txInfo.withdrawalDetails.exchangeCreditAccountDetails);
+ t.assertDeepEqual(
+ txInfo.withdrawalDetails.exchangeCreditAccountDetails[0].transferAmount,
+ "FOO:123",
+ );
+
+ // Check that the request did not go into long-polling.
+ const duration = AbsoluteTime.difference(tStart, AbsoluteTime.now());
+ if (typeof duration.d_ms !== "number" || duration.d_ms > 5 * 1000) {
+ throw Error("withdrawal took too long (longpolling issue)");
+ }
+
+ const reservePub: string = wres.reservePub;
+
+ const wireGatewayApiClient = new WireGatewayApiClient(
+ exchangeBankAccount.wireGatewayApiBaseUrl,
+ {
+ auth: {
+ username: exchangeBankAccount.accountName,
+ password: exchangeBankAccount.accountPassword,
+ },
+ },
+ );
+
+ await wireGatewayApiClient.adminAddIncoming({
+ amount: "TESTKUDOS:10",
+ debitAccountPayto: user.accountPaytoUri,
+ reservePub: reservePub,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Check balance
+
+ const balResp = await walletClient.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
+}
+
+runWithdrawalConversionTest.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..1dc955649
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.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 { AmountString, URL } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ WalletCli,
+ setupDb,
+} from "../harness/harness.js";
+
+/**
+ * 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(
+ "/accounts/exchange/taler-wire-gateway/",
+ bank.baseUrl,
+ ).href,
+ accountPaytoUri:
+ "payto://x-taler-bank/localhost/exchange?receiver-name=Exchange",
+ });
+
+ await bank.createExchangeAccount("exchange", "x");
+
+ 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.WithdrawTestBalance, {
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10" as AmountString,
+ });
+
+ 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);
+}
+
+runWithdrawalFakebankTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts
new file mode 100644
index 000000000..1c65de7d9
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts
@@ -0,0 +1,192 @@
+/*
+ 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 { TalerCorebankApiClient, j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ WalletCli,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+
+const coinRsaCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ rsaKeySize: 1024,
+};
+
+const coin_u1 = (curr: string): CoinConfig => ({
+ ...coinRsaCommon,
+ name: `${curr}_u1`,
+ value: `${curr}:1`,
+ feeDeposit: `${curr}:0`,
+ feeRefresh: `${curr}:0`,
+ feeRefund: `${curr}:0`,
+ feeWithdraw: `${curr}:1`,
+});
+
+const coin_u5 = (curr: string): CoinConfig => ({
+ ...coinRsaCommon,
+ name: `${curr}_u5`,
+ value: `${curr}:5`,
+ feeDeposit: `${curr}:0`,
+ feeRefresh: `${curr}:0`,
+ feeRefund: `${curr}:0`,
+ feeWithdraw: `${curr}:1`,
+});
+
+export const weirdCoinConfig = [coin_u1, coin_u5];
+
+/**
+ * Test withdrawal with a weird denomination structure to
+ * make sure fees are computed as expected.
+ */
+export async function runWithdrawalFeesTest(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,
+ });
+
+ let receiverName = "Exchange";
+ let exchangeBankUsername = "exchange";
+ let exchangeBankPassword = "mypw";
+ let exchangePaytoUri = generateRandomPayto(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPassword,
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, exchangePaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: receiverName,
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ const coinConfig: CoinConfig[] = weirdCoinConfig.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,
+ });
+
+ const amount = "TESTKUDOS:7.5";
+
+ const user = await bankClient.createRandomBankUser();
+ bankClient.setAuth(user);
+ const wop = await bankClient.createWithdrawalOperation(
+ user.username,
+ amount,
+ );
+
+ // Hand it to the wallet
+
+ const details = await wallet.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ console.log(j2s(details));
+
+ const amountDetails = await wallet.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForAmount,
+ {
+ amount: details.amount,
+ exchangeBaseUrl: details.possibleExchanges[0].exchangeBaseUrl,
+ },
+ );
+
+ console.log(j2s(amountDetails));
+
+ t.assertAmountEquals(amountDetails.amountEffective, "TESTKUDOS:5");
+ t.assertAmountEquals(amountDetails.amountRaw, "TESTKUDOS:7.5");
+
+ await wallet.runPending();
+
+ // Withdraw (AKA select)
+
+ await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+
+ // Confirm it
+
+ await bankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+ await wallet.runUntilDone();
+
+ // Check balance
+
+ const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ console.log(j2s(balResp));
+
+ t.assertAmountEquals(balResp.balances[0].available, "TESTKUDOS:5");
+
+ const txns = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ console.log(j2s(txns));
+}
+
+runWithdrawalFeesTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-handover.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-handover.ts
new file mode 100644
index 000000000..9fbdb81a4
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-handover.ts
@@ -0,0 +1,194 @@
+/*
+ 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 {
+ TalerCorebankApiClient,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+} from "../harness/helpers.js";
+
+/**
+ * Test handing over a withdrawal to another wallet.
+ */
+export async function runWithdrawalHandoverTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Do one normal withdrawal with the new split API
+ {
+ // Create a withdrawal operation
+
+ const user = await bankClient.createRandomBankUser();
+ const userBankClient = new TalerCorebankApiClient(bankClient.baseUrl);
+ userBankClient.setAuth(user);
+ const amount = "TESTKUDOS:10"
+ const wop = await userBankClient.createWithdrawalOperation(
+ user.username,
+ amount,
+ );
+
+ const checkResp = await walletClient.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ t.assertTrue(!!checkResp.defaultExchangeBaseUrl);
+
+ const prepareResp = await walletClient.call(
+ WalletApiOperation.PrepareBankIntegratedWithdrawal,
+ {
+ // exchangeBaseUrl: checkResp.defaultExchangeBaseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ console.log(`prepareResp: ${j2s(prepareResp)}`);
+
+ const txns1 = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ });
+ console.log(j2s(txns1));
+
+ await walletClient.call(WalletApiOperation.ConfirmWithdrawal, {
+ transactionId: prepareResp.transactionId,
+ amount,
+ exchangeBaseUrl: checkResp.defaultExchangeBaseUrl,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareResp.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ },
+ });
+
+ await userBankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareResp.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ }
+
+ // Do one another withdrawal with handover.
+ {
+ t.logStep("start-subtest-handover");
+
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ });
+
+ // Create a withdrawal operation
+
+ const user = await bankClient.createRandomBankUser();
+ const userBankClient = new TalerCorebankApiClient(bankClient.baseUrl);
+ userBankClient.setAuth(user);
+ const amount = "TESTKUDOS:10";
+
+ const wop = await userBankClient.createWithdrawalOperation(
+ user.username,
+ amount,
+ );
+
+ const checkResp = await walletClient.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ t.assertTrue(!!checkResp.defaultExchangeBaseUrl);
+
+ const prepareRespW1 = await walletClient.call(
+ WalletApiOperation.PrepareBankIntegratedWithdrawal,
+ {
+ // exchangeBaseUrl: checkResp.defaultExchangeBaseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ const prepareRespW2 = await w2.walletClient.call(
+ WalletApiOperation.PrepareBankIntegratedWithdrawal,
+ {
+ // exchangeBaseUrl: checkResp.defaultExchangeBaseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ await w2.walletClient.call(WalletApiOperation.ConfirmWithdrawal, {
+ transactionId: prepareRespW2.transactionId,
+ amount,
+ exchangeBaseUrl: checkResp.defaultExchangeBaseUrl,
+ });
+
+ await w2.walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareRespW2.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ },
+ });
+
+ await userBankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ console.log(`wopid is ${wop.withdrawal_id}`);
+
+ t.logStep("start-wait-w2-done");
+ await w2.walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareRespW2.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ t.logStep("done-wait-w2-done");
+
+ t.logStep("start-wait-w1-done");
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareRespW1.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.CompletedByOtherWallet,
+ },
+ });
+
+ t.logStep("done-wait-w1-done");
+ }
+}
+
+runWithdrawalHandoverTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts
new file mode 100644
index 000000000..aaa6701f8
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts
@@ -0,0 +1,140 @@
+/*
+ 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,
+ ExchangeService,
+ WalletService,
+ WalletClient,
+ BankService,
+} from "../harness/harness.js";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ AmountString,
+ NotificationType,
+ TalerCorebankApiClient,
+ TransactionMajorState,
+ URL,
+} from "@gnu-taler/taler-util";
+
+/**
+ * Withdraw a high amount. Mostly intended as a perf test.
+ *
+ * It is useful to see whether the wallet stays responsive while doing a huge withdrawal.
+ * (This is not automatic yet. Use taler-wallet-cli to connect to the daemon and make requests to check.)
+ */
+export async function runWithdrawalHugeTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await BankService.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,
+ });
+
+ let paytoUri = "payto://x-taler-bank/localhost/exchange";
+
+ await exchange.addBankAccount("1", {
+ accountName: "exchange",
+ accountPassword: "x",
+ wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href,
+ accountPaytoUri: paytoUri,
+ });
+
+ bank.setSuggestedExchange(exchange, paytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ }
+ });
+
+ await bankClient.registerAccountExtended({
+ name: "Exchange",
+ password: "x",
+ username: "exchange",
+ is_taler_exchange: true,
+ payto_uri: paytoUri,
+ });
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ console.log("setup done!");
+
+ const walletService = new WalletService(t, { name: "w1" });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const wallet = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ });
+ await wallet.connect();
+
+ const withdrawalFinishedCond = wallet.waitForNotificationCond(
+ (wn) =>
+ wn.type === NotificationType.TransactionStateTransition &&
+ wn.transactionId.startsWith("txn:withdrawal:") &&
+ wn.newTxState.major === TransactionMajorState.Done,
+ );
+
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ // Results in about 1K coins withdrawn
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10000" as AmountString,
+ corebankApiBaseUrl: bank.baseUrl,
+ });
+
+ await withdrawalFinishedCond;
+
+ // Check balance
+
+ const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ console.log(balResp);
+}
+
+runWithdrawalHugeTest.suites = ["wallet-perf"];
+// FIXME: Should not be "experimental" but "slow" or something similar.
+runWithdrawalHugeTest.experimental = 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..cd7d137cc
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts
@@ -0,0 +1,103 @@
+/*
+ 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,
+ TalerCorebankApiClient,
+ Logger,
+ WireGatewayApiClient,
+ j2s,
+ AmountString,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV3 } from "../harness/helpers.js";
+
+const logger = new Logger("test-withdrawal-manual.ts");
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWithdrawalManualTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, exchangeBankAccount } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Create a withdrawal operation
+
+ const user = await bankClient.createRandomBankUser();
+
+ await walletClient.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const tStart = AbsoluteTime.now();
+
+ logger.info("starting AcceptManualWithdrawal request");
+ // We expect this to return immediately.
+
+ const wres = await walletClient.call(
+ WalletApiOperation.AcceptManualWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10" as AmountString,
+ },
+ );
+
+ logger.info("AcceptManualWithdrawal finished");
+ logger.info(`result: ${j2s(wres)}`);
+
+ // Check that the request did not go into long-polling.
+ const duration = AbsoluteTime.difference(tStart, AbsoluteTime.now());
+ if (typeof duration.d_ms !== "number" || duration.d_ms > 5 * 1000) {
+ throw Error("withdrawal took too long (longpolling issue)");
+ }
+
+ const reservePub: string = wres.reservePub;
+
+ const wireGatewayApiClient = new WireGatewayApiClient(
+ exchangeBankAccount.wireGatewayApiBaseUrl,
+ {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ },
+ );
+
+ await wireGatewayApiClient.adminAddIncoming({
+ amount: "TESTKUDOS:10",
+ debitAccountPayto: user.accountPaytoUri,
+ reservePub: reservePub,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Check balance
+
+ const balResp = await walletClient.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
+
+ await t.shutdown();
+}
+
+runWithdrawalManualTest.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..eb2ae7fa6
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -0,0 +1,599 @@
+/*
+ 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, Logger, minimatch } from "@gnu-taler/taler-util";
+import * as child_process from "child_process";
+import { spawnSync } from "child_process";
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import url from "url";
+import {
+ GlobalTestState,
+ TestRunResult,
+ runTestWithState,
+ shouldLingerInTest,
+} from "../harness/harness.js";
+import { getSharedTestDir } from "../harness/helpers.js";
+import { runAgeRestrictionsDepositTest } from "./test-age-restrictions-deposit.js";
+import { runAgeRestrictionsMerchantTest } from "./test-age-restrictions-merchant.js";
+import { runAgeRestrictionsMixedMerchantTest } from "./test-age-restrictions-mixed-merchant.js";
+import { runAgeRestrictionsPeerTest } from "./test-age-restrictions-peer.js";
+import { runBankApiTest } from "./test-bank-api.js";
+import { runClaimLoopTest } from "./test-claim-loop.js";
+import { runClauseSchnorrTest } from "./test-clause-schnorr.js";
+import { runCurrencyScopeTest } from "./test-currency-scope.js";
+import { runDenomLostTest } from "./test-denom-lost.js";
+import { runDenomUnofferedTest } from "./test-denom-unoffered.js";
+import { runDepositTest } from "./test-deposit.js";
+import { runExchangeDepositTest } from "./test-exchange-deposit.js";
+import { runExchangeManagementFaultTest } from "./test-exchange-management-fault.js";
+import { runExchangeManagementTest } from "./test-exchange-management.js";
+import { runExchangePurseTest } from "./test-exchange-purse.js";
+import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js";
+import { runFeeRegressionTest } from "./test-fee-regression.js";
+import { runForcedSelectionTest } from "./test-forced-selection.js";
+import { runKycTest } from "./test-kyc.js";
+import { runLibeufinBankTest } from "./test-libeufin-bank.js";
+import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion.js";
+import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete.js";
+import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls.js";
+import { runMerchantInstancesTest } from "./test-merchant-instances.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 { runMultiExchangeTest } from "./test-multiexchange.js";
+import { runOtpTest } from "./test-otp.js";
+import { runPayPaidTest } from "./test-pay-paid.js";
+import { runPaymentAbortTest } from "./test-payment-abort.js";
+import { runPaymentClaimTest } from "./test-payment-claim.js";
+import { runPaymentDeletedTest } from "./test-payment-deleted.js";
+import { runPaymentExpiredTest } from "./test-payment-expired.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 { runPaymentShareTest } from "./test-payment-share.js";
+import { runPaymentTemplateTest } from "./test-payment-template.js";
+import { runPaymentTransientTest } from "./test-payment-transient.js";
+import { runPaymentZeroTest } from "./test-payment-zero.js";
+import { runPaymentTest } from "./test-payment.js";
+import { runPaywallFlowTest } from "./test-paywall-flow.js";
+import { runPeerPullLargeTest } from "./test-peer-pull-large.js";
+import { runPeerPushLargeTest } from "./test-peer-push-large.js";
+import { runPeerRepairTest } from "./test-peer-repair.js";
+import { runPeerToPeerPullTest } from "./test-peer-to-peer-pull.js";
+import { runPeerToPeerPushTest } from "./test-peer-to-peer-push.js";
+import { runRefundAutoTest } from "./test-refund-auto.js";
+import { runRefundGoneTest } from "./test-refund-gone.js";
+import { runRefundIncrementalTest } from "./test-refund-incremental.js";
+import { runRefundTest } from "./test-refund.js";
+import { runRevocationTest } from "./test-revocation.js";
+import { runSimplePaymentTest } from "./test-simple-payment.js";
+import { runStoredBackupsTest } from "./test-stored-backups.js";
+import { runTimetravelAutorefreshTest } from "./test-timetravel-autorefresh.js";
+import { runTimetravelWithdrawTest } from "./test-timetravel-withdraw.js";
+import { runTermOfServiceFormatTest } from "./test-tos-format.js";
+import { runWalletBackupBasicTest } from "./test-wallet-backup-basic.js";
+import { runWalletBackupDoublespendTest } from "./test-wallet-backup-doublespend.js";
+import { runWalletBalanceNotificationsTest } from "./test-wallet-balance-notifications.js";
+import { runWalletBalanceZeroTest } from "./test-wallet-balance-zero.js";
+import { runWalletBalanceTest } from "./test-wallet-balance.js";
+import { runWalletBlockedDepositTest } from "./test-wallet-blocked-deposit.js";
+import { runWalletBlockedPayMerchantTest } from "./test-wallet-blocked-pay-merchant.js";
+import { runWalletBlockedPayPeerPullTest } from "./test-wallet-blocked-pay-peer-pull.js";
+import { runWalletBlockedPayPeerPushTest } from "./test-wallet-blocked-pay-peer-push.js";
+import { runWalletCliTerminationTest } from "./test-wallet-cli-termination.js";
+import { runWalletConfigTest } from "./test-wallet-config.js";
+import { runWalletCryptoWorkerTest } from "./test-wallet-cryptoworker.js";
+import { runWalletDblessTest } from "./test-wallet-dbless.js";
+import { runWalletDd48Test } from "./test-wallet-dd48.js";
+import { runWalletDenomExpireTest } from "./test-wallet-denom-expire.js";
+import { runWalletDevExperimentsTest } from "./test-wallet-dev-experiments.js";
+import { runWalletExchangeUpdateTest } from "./test-wallet-exchange-update.js";
+import { runWalletGenDbTest } from "./test-wallet-gendb.js";
+import { runWalletInsufficientBalanceTest } from "./test-wallet-insufficient-balance.js";
+import { runWalletNotificationsTest } from "./test-wallet-notifications.js";
+import { runWalletObservabilityTest } from "./test-wallet-observability.js";
+import { runWalletRefreshErrorsTest } from "./test-wallet-refresh-errors.js";
+import { runWalletRefreshTest } from "./test-wallet-refresh.js";
+import { runWalletWirefeesTest } from "./test-wallet-wirefees.js";
+import { runWallettestingTest } from "./test-wallettesting.js";
+import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank.js";
+import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated.js";
+import { runWithdrawalConversionTest } from "./test-withdrawal-conversion.js";
+import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js";
+import { runWithdrawalFeesTest } from "./test-withdrawal-fees.js";
+import { runWithdrawalHandoverTest } from "./test-withdrawal-handover.js";
+import { runWithdrawalHugeTest } from "./test-withdrawal-huge.js";
+import { runWithdrawalManualTest } from "./test-withdrawal-manual.js";
+import { runWithdrawalAmountTest } from "./test-withdrawal-amount.js";
+
+/**
+ * Test runner.
+ */
+const logger = new Logger("testrunner.ts");
+
+/**
+ * Spec for one test.
+ */
+interface TestMainFunction {
+ (t: GlobalTestState): Promise<void>;
+ timeoutMs?: number;
+ experimental?: boolean;
+ suites?: string[];
+}
+
+const allTests: TestMainFunction[] = [
+ runAgeRestrictionsMerchantTest,
+ runAgeRestrictionsMixedMerchantTest,
+ runAgeRestrictionsPeerTest,
+ runAgeRestrictionsDepositTest,
+ runBankApiTest,
+ runClaimLoopTest,
+ runClauseSchnorrTest,
+ runDenomUnofferedTest,
+ runDepositTest,
+ runSimplePaymentTest,
+ runExchangeManagementFaultTest,
+ runExchangeTimetravelTest,
+ runFeeRegressionTest,
+ runForcedSelectionTest,
+ runKycTest,
+ runExchangePurseTest,
+ runExchangeDepositTest,
+ runMerchantExchangeConfusionTest,
+ runMerchantInstancesDeleteTest,
+ runMerchantInstancesTest,
+ runMerchantInstancesUrlsTest,
+ runMerchantLongpollingTest,
+ runMerchantRefundApiTest,
+ runMerchantSpecPublicOrdersTest,
+ runPaymentClaimTest,
+ runPaymentFaultTest,
+ runPaymentForgettableTest,
+ runPaymentIdempotencyTest,
+ runPaymentMultipleTest,
+ runPaymentTest,
+ runPaymentShareTest,
+ runPaymentTemplateTest,
+ runPaymentAbortTest,
+ runPaymentTransientTest,
+ runPaymentZeroTest,
+ runPayPaidTest,
+ runPeerRepairTest,
+ runMultiExchangeTest,
+ runWalletBalanceTest,
+ runPaywallFlowTest,
+ runPeerToPeerPullTest,
+ runPeerToPeerPushTest,
+ runRefundAutoTest,
+ runRefundGoneTest,
+ runRefundIncrementalTest,
+ runRefundTest,
+ runRevocationTest,
+ runWithdrawalManualTest,
+ runTimetravelAutorefreshTest,
+ runTimetravelWithdrawTest,
+ runWalletBackupBasicTest,
+ runWalletBackupDoublespendTest,
+ runWalletNotificationsTest,
+ runWalletCryptoWorkerTest,
+ runWalletDblessTest,
+ runWallettestingTest,
+ runWithdrawalAbortBankTest,
+ // runWithdrawalNotifyBeforeTxTest,
+ runWithdrawalBankIntegratedTest,
+ runWithdrawalFakebankTest,
+ runWithdrawalFeesTest,
+ runWithdrawalConversionTest,
+ runWithdrawalHugeTest,
+ runTermOfServiceFormatTest,
+ runStoredBackupsTest,
+ runPaymentExpiredTest,
+ runWalletGenDbTest,
+ runLibeufinBankTest,
+ runPaymentDeletedTest,
+ runWalletDd48Test,
+ runCurrencyScopeTest,
+ runWalletRefreshTest,
+ runWalletCliTerminationTest,
+ runOtpTest,
+ runWalletBalanceNotificationsTest,
+ runExchangeManagementTest,
+ runWalletConfigTest,
+ runWalletObservabilityTest,
+ runWalletDevExperimentsTest,
+ runWalletBalanceZeroTest,
+ runWalletInsufficientBalanceTest,
+ runWalletWirefeesTest,
+ runDenomLostTest,
+ runWalletDenomExpireTest,
+ runWalletBlockedDepositTest,
+ runWalletBlockedPayMerchantTest,
+ runWalletBlockedPayPeerPushTest,
+ runWalletBlockedPayPeerPullTest,
+ runWalletExchangeUpdateTest,
+ runWalletRefreshErrorsTest,
+ runPeerPullLargeTest,
+ runPeerPushLargeTest,
+ runWithdrawalHandoverTest,
+ runWithdrawalAmountTest,
+];
+
+export interface TestRunSpec {
+ includePattern?: string;
+ suiteSpec?: string;
+ dryRun?: boolean;
+ failFast?: boolean;
+ waitOnFail?: boolean;
+ includeExperimental: boolean;
+ noTimeout: boolean;
+ verbosity: number;
+}
+
+export interface TestInfo {
+ name: string;
+ suites: string[];
+ experimental: 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;
+}
+
+function purgeSharedTestEnvironment() {
+ const rmRes = spawnSync("rm", ["-rf", `${getSharedTestDir()}`]);
+ if (rmRes.status != 0) {
+ logger.warn("can't delete shared test directory");
+ }
+ const psqlRes = spawnSync("psql", ["-Aqtl"], {
+ encoding: "utf-8",
+ });
+ if (psqlRes.status != 0) {
+ logger.warn("could not list available postgres databases");
+ return;
+ }
+ if (psqlRes.output[1]!!.indexOf("taler-integrationtest-shared") >= 0) {
+ const dropRes = spawnSync("dropdb", ["taler-integrationtest-shared"], {
+ encoding: "utf-8",
+ });
+ if (dropRes.status != 0) {
+ logger.warn("could not drop taler-integrationtest-shared database");
+ return;
+ }
+ }
+}
+
+export async function runTests(spec: TestRunSpec) {
+ if (!process.env.TALER_HARNESS_KEEP) {
+ logger.info("purging shared test environment");
+ purgeSharedTestEnvironment();
+ } else {
+ logger.info("keeping shared test environment");
+ }
+
+ 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 (testCase.experimental && !spec.includeExperimental) {
+ continue;
+ }
+
+ if (suites) {
+ const ts = new Set(testCase.suites ?? []);
+ const intersection = new Set([...suites].filter((x) => ts.has(x)));
+ if (intersection.size === 0) {
+ 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);
+
+ // Default timeout when the test doesn't override it.
+ let defaultTimeout = 60000;
+ const overrideDefaultTimeout = process.env.TALER_TEST_TIMEOUT;
+ if (overrideDefaultTimeout) {
+ defaultTimeout = Number.parseInt(overrideDefaultTimeout, 10) * 1000;
+ }
+
+ // Set the timeout to at least be the default timeout.
+ const testTimeoutMs = testCase.timeoutMs
+ ? Math.max(testCase.timeoutMs, defaultTimeout)
+ : defaultTimeout;
+
+ if (spec.noTimeout) {
+ console.log(`running ${testName}, no timeout`);
+ } else {
+ console.log(`running ${testName} with timeout ${testTimeoutMs}ms`);
+ }
+
+ const token = spec.noTimeout
+ ? CancellationToken.CONTINUE
+ : CancellationToken.timeout(testTimeoutMs).token;
+
+ 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;
+ }
+ logger.info(`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);
+ if (result.status === "fail" && spec.failFast) {
+ logger.error("test failed and failing fast, exit!");
+ throw Error("exit on fail fast");
+ }
+ } 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 ?? [],
+ experimental: x.experimental ?? 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;
+
+ process.on("disconnect", () => {
+ logger.trace("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) {
+ logger.error("can't communicate with parent");
+ process.exit(2);
+ }
+
+ if (!testMain) {
+ logger.info(`test ${testName} not found`);
+ process.exit(2);
+ }
+
+ const testDir = path.join(testRootDir, testName);
+ logger.info(`running test ${testName}`);
+ const gc = new GlobalTestState({
+ testDir,
+ });
+ const testResult = await runTestWithState(gc, testMain, testName);
+ logger.info(`done test ${testName}: ${testResult.status}`);
+ process.send(testResult);
+ };
+
+ runTest()
+ .then(() => {
+ logger.trace(`test ${testName} finished in worker`);
+ if (shouldLingerInTest()) {
+ logger.trace("lingering ...");
+ return;
+ }
+ process.exit(0);
+ })
+ .catch((e) => {
+ logger.error(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..a45e6db9d
--- /dev/null
+++ b/packages/taler-harness/src/lint.ts
@@ -0,0 +1,536 @@
+/*
+ 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 { URL } from "url";
+import { spawn } from "child_process";
+import { delayMs } from "./harness/harness.js";
+import {
+ createPlatformHttpLib,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-util/http";
+
+interface BasicConf {
+ mainCurrency: string;
+}
+
+interface PubkeyConf {
+ masterPublicKey: string;
+}
+
+const httpLib = createPlatformHttpLib({
+ enableThrottling: false,
+});
+
+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.isDefined()) {
+ context.numErr++;
+ console.log("error: currency not defined in section TALER option CURRENCY");
+ console.log("Aborting further checks.");
+ process.exit(1);
+ } else {
+ mainCurrency = currencyEntry.required().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.fetch(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.fetch(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.fetch(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/src/sandcastle-config.ts b/packages/taler-harness/src/sandcastle-config.ts
new file mode 100644
index 000000000..a7f7233ac
--- /dev/null
+++ b/packages/taler-harness/src/sandcastle-config.ts
@@ -0,0 +1,10 @@
+// Work in progress.
+// TS-based schema for the sandcastle configuration.
+
+export interface SandcastleConfig {
+ currency: string;
+ merchant: {
+ apiKey: string;
+ baseUrl: string;
+ };
+}