diff options
Diffstat (limited to 'packages/taler-harness/src')
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; + }; +} |