diff options
author | Florian Dold <florian@dold.me> | 2021-01-12 20:04:16 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2021-01-12 20:04:16 +0100 |
commit | a5681579fbddb001f5b7118fe705c6643581c722 (patch) | |
tree | c8bd46e6bf7a5c97ee3db676eae9405bfdf4d2b2 /packages/taler-wallet-cli/src/integrationtests/harness.ts | |
parent | 6772c5479394cbdf404857f75263749a5c91bd41 (diff) | |
download | wallet-core-a5681579fbddb001f5b7118fe705c6643581c722.tar.xz |
make integration tests part of taler-wallet-cli
Diffstat (limited to 'packages/taler-wallet-cli/src/integrationtests/harness.ts')
-rw-r--r-- | packages/taler-wallet-cli/src/integrationtests/harness.ts | 1718 |
1 files changed, 1718 insertions, 0 deletions
diff --git a/packages/taler-wallet-cli/src/integrationtests/harness.ts b/packages/taler-wallet-cli/src/integrationtests/harness.ts new file mode 100644 index 000000000..108b78540 --- /dev/null +++ b/packages/taler-wallet-cli/src/integrationtests/harness.ts @@ -0,0 +1,1718 @@ +/* + 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 * as util from "util"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import * as http from "http"; +import { deepStrictEqual } from "assert"; +import { ChildProcess, spawn } from "child_process"; +import { + Configuration, + AmountJson, + Amounts, + Codec, + buildCodecForObject, + codecForString, + Duration, + CoreApiResponse, + PreparePayResult, + PreparePayRequest, + codecForPreparePayResult, + OperationFailedError, + AddExchangeRequest, + ExchangesListRespose, + codecForExchangesListResponse, + GetWithdrawalDetailsForUriRequest, + WithdrawUriInfoResponse, + codecForWithdrawUriInfoResponse, + ConfirmPayRequest, + ConfirmPayResult, + codecForConfirmPayResult, + IntegrationTestArgs, + TestPayArgs, + BalancesResponse, + codecForBalancesResponse, + encodeCrock, + getRandomBytes, + EddsaKeyPair, + eddsaGetPublic, + createEddsaKeyPair, + TransactionsResponse, + codecForTransactionsResponse, + WithdrawTestBalanceRequest, + AmountString, + ApplyRefundRequest, + codecForApplyRefundResponse, + codecForAny, + CoinDumpJson, + ForceExchangeUpdateRequest, + ForceRefreshRequest, + PrepareTipResult, + PrepareTipRequest, + codecForPrepareTipResult, + AcceptTipRequest, + AbortPayWithRefundRequest, + handleWorkerError, +} from "taler-wallet-core"; +import { URL } from "url"; +import axios, { AxiosError } from "axios"; +import { + codecForMerchantOrderPrivateStatusResponse, + codecForPostOrderResponse, + PostOrderRequest, + PostOrderResponse, + MerchantOrderPrivateStatusResponse, + TippingReserveStatus, + TipCreateConfirmation, + TipCreateRequest, +} from "./merchantApiTypes"; +import { ApplyRefundResponse } from "taler-wallet-core"; +import { PendingOperationsResponse } from "taler-wallet-core"; +import { CoinConfig } from "./denomStructures"; +import { after } from "taler-wallet-core/src/util/timer"; + +const exec = util.promisify(require("child_process").exec); + +export async function delayMs(ms: number): Promise<void> { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(), ms); + }); +} + +interface WaitResult { + code: number | null; + signal: NodeJS.Signals | null; +} + +/** + * Run a shell command, return stdout. + */ +export async function sh( + t: GlobalTestState, + logName: string, + command: string, +): Promise<string> { + console.log("runing command", command); + return new Promise((resolve, reject) => { + const stdoutChunks: Buffer[] = []; + const proc = spawn(command, { + stdio: ["inherit", "pipe", "pipe"], + shell: true, + }); + 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) => { + console.log(`child process exited (${code} / ${signal})`); + if (code != 0) { + reject(Error(`Unexpected exit code ${code} for '${command}'`)); + return; + } + const b = Buffer.concat(stdoutChunks).toString("utf-8"); + resolve(b); + }); + proc.on("error", () => { + reject(Error("Child process had error")); + }); + }); +} + +function shellescape(args: string[]) { + const ret = args.map((s) => { + if (/[^A-Za-z0-9_\/:=-]/.test(s)) { + s = "'" + s.replace(/'/g, "'\\''") + "'"; + s = s.replace(/^(?:'')+/g, "").replace(/\\'''/g, "\\'"); + } + return s; + }); + return ret.join(" "); +} + +/** + * Run a shell command, return stdout. + * + * Log stderr to a log file. + */ +export async function runCommand( + t: GlobalTestState, + logName: string, + command: string, + args: string[], +): Promise<string> { + console.log("runing command", shellescape([command, ...args])); + return new Promise((resolve, reject) => { + const stdoutChunks: Buffer[] = []; + const proc = spawn(command, args, { + stdio: ["inherit", "pipe", "pipe"], + shell: false, + }); + 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) => { + console.log(`child process exited (${code} / ${signal})`); + if (code != 0) { + reject(Error(`Unexpected exit code ${code} for '${command}'`)); + return; + } + const b = Buffer.concat(stdoutChunks).toString("utf-8"); + resolve(b); + }); + proc.on("error", () => { + reject(Error("Child process had error")); + }); + }); +} + +export class ProcessWrapper { + private waitPromise: Promise<WaitResult>; + constructor(public proc: ChildProcess) { + this.waitPromise = new Promise((resolve, reject) => { + proc.on("exit", (code, signal) => { + resolve({ code, signal }); + }); + proc.on("error", (err) => { + reject(err); + }); + }); + } + + wait(): Promise<WaitResult> { + return this.waitPromise; + } +} + +export class GlobalTestParams { + testDir: string; +} + +export class GlobalTestState { + testDir: string; + procs: ProcessWrapper[]; + servers: http.Server[]; + inShutdown: boolean = false; + constructor(params: GlobalTestParams) { + this.testDir = params.testDir; + this.procs = []; + this.servers = []; + } + + async assertThrowsOperationErrorAsync( + block: () => Promise<void>, + ): Promise<OperationFailedError> { + try { + await block(); + } catch (e) { + if (e instanceof OperationFailedError) { + return e; + } + throw Error(`expected OperationFailedError to be thrown, but got ${e}`); + } + throw Error( + `expected OperationFailedError to be thrown, but block finished without throwing`, + ); + } + + async assertThrowsAsync(block: () => Promise<void>): Promise<any> { + try { + await block(); + } catch (e) { + return e; + } + throw Error( + `expected exception to be thrown, but block finished without throwing`, + ); + } + + assertAxiosError(e: any): asserts e is AxiosError { + return e.isAxiosError; + } + + 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, + ): ProcessWrapper { + console.log( + `spawning process (${logName}): ${shellescape([command, ...args])}`, + ); + const proc = spawn(command, args, { + stdio: ["inherit", "pipe", "pipe"], + }); + console.log(`spawned process (${logName}) with pid ${proc.pid}`); + proc.on("error", (err) => { + console.log(`could not start process (${command})`, err); + }); + proc.on("exit", (code, signal) => { + console.log(`process ${logName} exited`); + }); + 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; + } + this.inShutdown = true; + console.log("shutting down"); + for (const s of this.servers) { + s.close(); + s.removeAllListeners(); + } + for (const p of this.procs) { + if (p.proc.exitCode == null) { + console.log("killing process", p.proc.pid); + p.proc.kill("SIGTERM"); + await p.wait(); + } + } + } +} + +export interface TalerConfigSection { + options: Record<string, string | undefined>; +} + +export interface TalerConfig { + sections: Record<string, TalerConfigSection>; +} + +export interface DbInfo { + connStr: string; + dbname: string; +} + +export async function setupDb(gc: GlobalTestState): Promise<DbInfo> { + const dbname = "taler-integrationtest"; + await exec(`dropdb "${dbname}" || true`); + await exec(`createdb "${dbname}"`); + return { + connStr: `postgres:///${dbname}`, + dbname, + }; +} + +export interface BankConfig { + currency: string; + httpPort: number; + database: string; + allowRegistrations: boolean; + maxDebt?: string; +} + +function setPaths(config: Configuration, home: string) { + config.setString("paths", "taler_home", home); + config.setString("paths", "taler_runtime_dir", "$TALER_HOME/taler-runtime/"); + 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/"); + config.setString( + "paths", + "taler_runtime_dir", + "${TMPDIR:-${TMP:-/tmp}}/taler-system-runtime/", + ); +} + +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); + config.setString(s, "rsa_keysize", `${c.rsaKeySize}`); +} + +async function pingProc( + proc: ProcessWrapper | undefined, + url: string, + serviceName: string, +): Promise<void> { + if (!proc || proc.proc.exitCode !== null) { + throw Error(`service process ${serviceName} not started, can't ping`); + } + while (true) { + try { + console.log(`pinging ${serviceName}`); + const resp = await axios.get(url); + console.log(`service ${serviceName} available`); + return; + } catch (e) { + console.log(`service ${serviceName} not ready:`, e.toString()); + await delayMs(1000); + } + if (!proc || proc.proc.exitCode !== null) { + throw Error(`service process ${serviceName} stopped unexpectedly`); + } + } +} + +export interface ExchangeBankAccount { + accountName: string; + accountPassword: string; + accountPaytoUri: string; + wireGatewayApiBaseUrl: string; +} + +export interface BankServiceInterface { + readonly baseUrl: string; + readonly port: number; +} + +export enum CreditDebitIndicator { + Credit = "credit", + Debit = "debit", +} + +export interface BankAccountBalanceResponse { + balance: { + amount: AmountString; + credit_debit_indicator: CreditDebitIndicator; + }; +} + +export namespace BankAccessApi { + export async function getAccountBalance( + bank: BankServiceInterface, + bankUser: BankUser, + ): Promise<BankAccountBalanceResponse> { + const url = new URL(`accounts/${bankUser.username}`, bank.baseUrl); + const resp = await axios.get(url.href, { + auth: bankUser, + }); + return resp.data; + } + + export async function createWithdrawalOperation( + bank: BankServiceInterface, + bankUser: BankUser, + amount: string, + ): Promise<WithdrawalOperationInfo> { + const url = new URL( + `accounts/${bankUser.username}/withdrawals`, + bank.baseUrl, + ); + const resp = await axios.post( + url.href, + { + amount, + }, + { + auth: bankUser, + }, + ); + return codecForWithdrawalOperationInfo().decode(resp.data); + } +} + +export namespace BankApi { + export async function registerAccount( + bank: BankServiceInterface, + username: string, + password: string, + ): Promise<BankUser> { + const url = new URL("testing/register", bank.baseUrl); + await axios.post(url.href, { + username, + password, + }); + return { + password, + username, + accountPaytoUri: `payto://x-taler-bank/localhost/${username}`, + }; + } + + export async function createRandomBankUser( + bank: BankServiceInterface, + ): Promise<BankUser> { + const username = "user-" + encodeCrock(getRandomBytes(10)); + const password = "pw-" + encodeCrock(getRandomBytes(10)); + return await registerAccount(bank, username, password); + } + + export async function adminAddIncoming( + bank: BankServiceInterface, + params: { + exchangeBankAccount: ExchangeBankAccount; + amount: string; + reservePub: string; + debitAccountPayto: string; + }, + ) { + const url = new URL( + `taler-wire-gateway/${params.exchangeBankAccount.accountName}/admin/add-incoming`, + bank.baseUrl, + ); + await axios.post( + url.href, + { + amount: params.amount, + reserve_pub: params.reservePub, + debit_account: params.debitAccountPayto, + }, + { + auth: { + username: params.exchangeBankAccount.accountName, + password: params.exchangeBankAccount.accountPassword, + }, + }, + ); + } + + export async function confirmWithdrawalOperation( + bank: BankServiceInterface, + bankUser: BankUser, + wopi: WithdrawalOperationInfo, + ): Promise<void> { + const url = new URL( + `accounts/${bankUser.username}/withdrawals/${wopi.withdrawal_id}/confirm`, + bank.baseUrl, + ); + await axios.post( + url.href, + {}, + { + auth: bankUser, + }, + ); + } + + export async function abortWithdrawalOperation( + bank: BankServiceInterface, + bankUser: BankUser, + wopi: WithdrawalOperationInfo, + ): Promise<void> { + const url = new URL( + `accounts/${bankUser.username}/withdrawals/${wopi.withdrawal_id}/abort`, + bank.baseUrl, + ); + await axios.post( + url.href, + {}, + { + auth: bankUser, + }, + ); + } +} + +export class BankService implements BankServiceInterface { + proc: ProcessWrapper | undefined; + + static fromExistingConfig(gc: GlobalTestState): BankService { + const cfgFilename = gc.testDir + "/bank.conf"; + console.log("reading bank config from", cfgFilename); + const config = Configuration.load(cfgFilename); + const bc: BankConfig = { + allowRegistrations: config + .getYesNo("bank", "allow_registrations") + .required(), + currency: config.getString("taler", "currency").required(), + database: config.getString("bank", "database").required(), + httpPort: config.getNumber("bank", "http_port").required(), + }; + return new BankService(gc, bc, cfgFilename); + } + + static async create( + gc: GlobalTestState, + bc: BankConfig, + ): Promise<BankService> { + const config = new Configuration(); + setPaths(config, gc.testDir + "/talerhome"); + config.setString("taler", "currency", bc.currency); + config.setString("bank", "database", bc.database); + 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", + "allow_registrations", + bc.allowRegistrations ? "yes" : "no", + ); + const cfgFilename = gc.testDir + "/bank.conf"; + config.write(cfgFilename); + + await sh( + gc, + "taler-bank-manage_django", + `taler-bank-manage -c '${cfgFilename}' django migrate`, + ); + await sh( + gc, + "taler-bank-manage_django", + `taler-bank-manage -c '${cfgFilename}' django provide_accounts`, + ); + + return new BankService(gc, bc, cfgFilename); + } + + setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) { + const config = Configuration.load(this.configFile); + config.setString("bank", "suggested_exchange", e.baseUrl); + config.setString("bank", "suggested_exchange_payto", exchangePayto); + } + + get baseUrl(): string { + return `http://localhost:${this.bankConfig.httpPort}/`; + } + + async createExchangeAccount( + accountName: string, + password: string, + ): Promise<ExchangeBankAccount> { + await sh( + this.globalTestState, + "taler-bank-manage_django", + `taler-bank-manage -c '${this.configFile}' django add_bank_account ${accountName}`, + ); + await sh( + this.globalTestState, + "taler-bank-manage_django", + `taler-bank-manage -c '${this.configFile}' django changepassword_unsafe ${accountName} ${password}`, + ); + await sh( + this.globalTestState, + "taler-bank-manage_django", + `taler-bank-manage -c '${this.configFile}' django top_up ${accountName} ${this.bankConfig.currency}:100000`, + ); + return { + accountName: accountName, + accountPassword: password, + accountPaytoUri: `payto://x-taler-bank/${accountName}`, + wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/taler-wire-gateway/${accountName}/`, + }; + } + + get port() { + return this.bankConfig.httpPort; + } + + private constructor( + private globalTestState: GlobalTestState, + private bankConfig: BankConfig, + private configFile: string, + ) {} + + async start(): Promise<void> { + this.proc = this.globalTestState.spawnService( + "taler-bank-manage", + ["-c", this.configFile, "serve"], + "bank", + ); + } + + async pingUntilAvailable(): Promise<void> { + const url = `http://localhost:${this.bankConfig.httpPort}/config`; + await pingProc(this.proc, url, "bank"); + } +} + +export interface BankUser { + username: string; + password: string; + accountPaytoUri: string; +} + +export interface WithdrawalOperationInfo { + withdrawal_id: string; + taler_withdraw_uri: string; +} + +const codecForWithdrawalOperationInfo = (): Codec<WithdrawalOperationInfo> => + buildCodecForObject<WithdrawalOperationInfo>() + .property("withdrawal_id", codecForString()) + .property("taler_withdraw_uri", codecForString()) + .build("WithdrawalOperationInfo"); + +export interface ExchangeConfig { + name: string; + currency: string; + roundUnit?: string; + httpPort: number; + database: string; +} + +export interface ExchangeServiceInterface { + readonly baseUrl: string; + readonly port: number; + readonly name: string; + readonly masterPub: string; +} + +export class ExchangeService implements ExchangeServiceInterface { + static fromExistingConfig(gc: GlobalTestState, exchangeName: string) { + const cfgFilename = gc.testDir + `/exchange-${exchangeName}.conf`; + const config = Configuration.load(cfgFilename); + const ec: ExchangeConfig = { + currency: config.getString("taler", "currency").required(), + database: config.getString("exchangedb-postgres", "config").required(), + httpPort: config.getNumber("exchange", "port").required(), + name: exchangeName, + roundUnit: config.getString("taler", "currency_round_unit").required(), + }; + const privFile = config.getPath("exchange", "master_priv_file").required(); + const eddsaPriv = fs.readFileSync(privFile); + const keyPair: EddsaKeyPair = { + eddsaPriv, + eddsaPub: eddsaGetPublic(eddsaPriv), + }; + return new ExchangeService(gc, ec, cfgFilename, keyPair); + } + + private currentTimetravel: Duration | undefined; + + setTimetravel(t: Duration | undefined): void { + if (this.isRunning()) { + throw Error("can't set time travel while the exchange is running"); + } + this.currentTimetravel = t; + } + + private get timetravelArg(): string | undefined { + if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") { + // Convert to microseconds + return `--timetravel=+${this.currentTimetravel.d_ms * 1000}`; + } + return undefined; + } + + /** + * Return an empty array if no time travel is set, + * and an array with the time travel command line argument + * otherwise. + */ + private get timetravelArgArr(): string[] { + const tta = this.timetravelArg; + if (tta) { + return [tta]; + } + return []; + } + + async runWirewatchOnce() { + await runCommand( + this.globalState, + `exchange-${this.name}-wirewatch-once`, + "taler-exchange-wirewatch", + [...this.timetravelArgArr, "-c", this.configFilename, "-t"], + ); + } + + async runAggregatorOnce() { + await runCommand( + this.globalState, + `exchange-${this.name}-aggregator-once`, + "taler-exchange-aggregator", + [...this.timetravelArgArr, "-c", this.configFilename, "-t"], + ); + } + + static create(gc: GlobalTestState, e: ExchangeConfig) { + const config = new Configuration(); + config.setString("taler", "currency", e.currency); + config.setString( + "taler", + "currency_round_unit", + e.roundUnit ?? `${e.currency}:0.01`, + ); + setPaths(config, gc.testDir + "/talerhome"); + + config.setString( + "exchange", + "keydir", + "${TALER_DATA_HOME}/exchange/live-keys/", + ); + config.setString( + "exchage", + "revocation_dir", + "${TALER_DATA_HOME}/exchange/revocations", + ); + config.setString("exchange", "max_keys_caching", "forever"); + config.setString("exchange", "db", "postgres"); + config.setString( + "exchange-offline", + "master_priv_file", + "${TALER_DATA_HOME}/exchange/offline-keys/master.priv", + ); + config.setString("exchange", "serve", "tcp"); + config.setString("exchange", "port", `${e.httpPort}`); + config.setString("exchange", "signkey_duration", "4 weeks"); + config.setString("exchange", "legal_duraction", "2 years"); + config.setString("exchange", "lookahead_sign", "32 weeks 1 day"); + config.setString("exchange", "lookahead_provide", "4 weeks 1 day"); + + config.setString("exchangedb-postgres", "config", e.database); + + const exchangeMasterKey = createEddsaKeyPair(); + + config.setString( + "exchange", + "master_public_key", + encodeCrock(exchangeMasterKey.eddsaPub), + ); + + const masterPrivFile = config + .getPath("exchange-offline", "master_priv_file") + .required(); + + fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true }); + + fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv)); + + const cfgFilename = gc.testDir + `/exchange-${e.name}.conf`; + config.write(cfgFilename); + return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey); + } + + addOfferedCoins(offeredCoins: ((curr: string) => CoinConfig)[]) { + const config = Configuration.load(this.configFilename); + offeredCoins.forEach((cc) => + setCoin(config, cc(this.exchangeConfig.currency)), + ); + config.write(this.configFilename); + } + + addCoinConfigList(ccs: CoinConfig[]) { + const config = Configuration.load(this.configFilename); + ccs.forEach((cc) => setCoin(config, cc)); + config.write(this.configFilename); + } + + get masterPub() { + return encodeCrock(this.keyPair.eddsaPub); + } + + get port() { + return this.exchangeConfig.httpPort; + } + + async addBankAccount( + localName: string, + exchangeBankAccount: ExchangeBankAccount, + ): Promise<void> { + const config = Configuration.load(this.configFilename); + config.setString( + `exchange-account-${localName}`, + "wire_response", + `\${TALER_DATA_HOME}/exchange/account-${localName}.json`, + ); + config.setString( + `exchange-account-${localName}`, + "payto_uri", + exchangeBankAccount.accountPaytoUri, + ); + config.setString(`exchange-account-${localName}`, "enable_credit", "yes"); + config.setString(`exchange-account-${localName}`, "enable_debit", "yes"); + config.setString( + `exchange-account-${localName}`, + "wire_gateway_url", + exchangeBankAccount.wireGatewayApiBaseUrl, + ); + config.setString( + `exchange-account-${localName}`, + "wire_gateway_auth_method", + "basic", + ); + config.setString( + `exchange-account-${localName}`, + "username", + exchangeBankAccount.accountName, + ); + config.setString( + `exchange-account-${localName}`, + "password", + exchangeBankAccount.accountPassword, + ); + config.write(this.configFilename); + } + + exchangeHttpProc: ProcessWrapper | undefined; + exchangeWirewatchProc: ProcessWrapper | undefined; + + helperCryptoRsaProc: ProcessWrapper | undefined; + helperCryptoEddsaProc: ProcessWrapper | undefined; + + constructor( + private globalState: GlobalTestState, + private exchangeConfig: ExchangeConfig, + private configFilename: string, + private keyPair: EddsaKeyPair, + ) {} + + get name() { + return this.exchangeConfig.name; + } + + get baseUrl() { + return `http://localhost:${this.exchangeConfig.httpPort}/`; + } + + isRunning(): boolean { + return !!this.exchangeWirewatchProc || !!this.exchangeHttpProc; + } + + async stop(): Promise<void> { + const wirewatch = this.exchangeWirewatchProc; + if (wirewatch) { + wirewatch.proc.kill("SIGTERM"); + await wirewatch.wait(); + this.exchangeWirewatchProc = undefined; + } + const httpd = this.exchangeHttpProc; + if (httpd) { + httpd.proc.kill("SIGTERM"); + await httpd.wait(); + this.exchangeHttpProc = undefined; + } + const cryptoRsa = this.helperCryptoRsaProc; + if (cryptoRsa) { + cryptoRsa.proc.kill("SIGTERM"); + await cryptoRsa.wait(); + this.helperCryptoRsaProc = undefined; + } + const cryptoEddsa = this.helperCryptoEddsaProc; + if (cryptoEddsa) { + cryptoEddsa.proc.kill("SIGTERM"); + await cryptoEddsa.wait(); + this.helperCryptoRsaProc = undefined; + } + } + + /** + * 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, + ...this.timetravelArgArr, + "download", + "sign", + "upload", + ], + ); + + const accounts: string[] = []; + + const config = Configuration.load(this.configFilename); + for (const sectionName of config.getSectionNames()) { + if (sectionName.startsWith("exchange-account")) { + accounts.push(config.getString(sectionName, "payto_uri").required()); + } + } + + console.log("configuring bank accounts", accounts); + + for (const acc of accounts) { + await runCommand( + this.globalState, + "exchange-offline", + "taler-exchange-offline", + [ + "-c", + this.configFilename, + ...this.timetravelArgArr, + "enable-account", + acc, + "upload", + ], + ); + } + + const year = new Date().getFullYear(); + for (let i = year; i < year + 5; i++) { + await runCommand( + this.globalState, + "exchange-offline", + "taler-exchange-offline", + [ + "-c", + this.configFilename, + ...this.timetravelArgArr, + "wire-fee", + `${i}`, + "x-taler-bank", + `${this.exchangeConfig.currency}:0.01`, + `${this.exchangeConfig.currency}:0.01`, + "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, + ...this.timetravelArgArr, + "revoke-denomination", + denomPubHash, + "upload", + ], + ); + } + + async start(): Promise<void> { + if (this.isRunning()) { + throw Error("exchange is already running"); + } + await sh( + this.globalState, + "exchange-dbinit", + `taler-exchange-dbinit -c "${this.configFilename}"`, + ); + + this.helperCryptoEddsaProc = this.globalState.spawnService( + "taler-helper-crypto-eddsa", + ["-c", this.configFilename, ...this.timetravelArgArr], + `exchange-crypto-eddsa-${this.name}`, + ); + + this.helperCryptoRsaProc = this.globalState.spawnService( + "taler-helper-crypto-rsa", + ["-c", this.configFilename, ...this.timetravelArgArr], + `exchange-crypto-rsa-${this.name}`, + ); + + this.exchangeWirewatchProc = this.globalState.spawnService( + "taler-exchange-wirewatch", + ["-c", this.configFilename, ...this.timetravelArgArr], + `exchange-wirewatch-${this.name}`, + ); + + this.exchangeHttpProc = this.globalState.spawnService( + "taler-exchange-httpd", + [ + "-c", + this.configFilename, + "--num-threads", + "1", + ...this.timetravelArgArr, + ], + `exchange-httpd-${this.name}`, + ); + + await this.keyup(); + } + + async pingUntilAvailable(): Promise<void> { + const url = `http://localhost:${this.exchangeConfig.httpPort}/keys`; + await pingProc(this.exchangeHttpProc, url, `exchange (${this.name})`); + } +} + +export interface MerchantConfig { + name: string; + currency: string; + httpPort: number; + database: string; +} + +export interface PrivateOrderStatusQuery { + instance?: string; + orderId: string; + sessionId?: string; +} + +export interface MerchantServiceInterface { + makeInstanceBaseUrl(instanceName?: string): string; + readonly port: number; + readonly name: string; +} + +export namespace MerchantPrivateApi { + export async function createOrder( + merchantService: MerchantServiceInterface, + instanceName: string, + req: PostOrderRequest, + ): Promise<PostOrderResponse> { + const baseUrl = merchantService.makeInstanceBaseUrl(instanceName); + let url = new URL("private/orders", baseUrl); + const resp = await axios.post(url.href, req); + return codecForPostOrderResponse().decode(resp.data); + } + + export async function queryPrivateOrderStatus( + merchantService: MerchantServiceInterface, + query: PrivateOrderStatusQuery, + ): Promise<MerchantOrderPrivateStatusResponse> { + const reqUrl = new URL( + `private/orders/${query.orderId}`, + merchantService.makeInstanceBaseUrl(query.instance), + ); + if (query.sessionId) { + reqUrl.searchParams.set("session_id", query.sessionId); + } + const resp = await axios.get(reqUrl.href); + return codecForMerchantOrderPrivateStatusResponse().decode(resp.data); + } + + export async function giveRefund( + merchantService: MerchantServiceInterface, + r: { + instance: string; + orderId: string; + amount: string; + justification: string; + }, + ): Promise<{ talerRefundUri: string }> { + const reqUrl = new URL( + `private/orders/${r.orderId}/refund`, + merchantService.makeInstanceBaseUrl(r.instance), + ); + const resp = await axios.post(reqUrl.href, { + refund: r.amount, + reason: r.justification, + }); + return { + talerRefundUri: resp.data.taler_refund_uri, + }; + } + + export async function createTippingReserve( + merchantService: MerchantServiceInterface, + instance: string, + req: CreateMerchantTippingReserveRequest, + ): Promise<CreateMerchantTippingReserveConfirmation> { + const reqUrl = new URL( + `private/reserves`, + merchantService.makeInstanceBaseUrl(instance), + ); + const resp = await axios.post(reqUrl.href, req); + // FIXME: validate + return resp.data; + } + + export async function queryTippingReserves( + merchantService: MerchantServiceInterface, + instance: string, + ): Promise<TippingReserveStatus> { + const reqUrl = new URL( + `private/reserves`, + merchantService.makeInstanceBaseUrl(instance), + ); + const resp = await axios.get(reqUrl.href); + // FIXME: validate + return resp.data; + } + + export async function giveTip( + merchantService: MerchantServiceInterface, + instance: string, + req: TipCreateRequest, + ): Promise<TipCreateConfirmation> { + const reqUrl = new URL( + `private/tips`, + merchantService.makeInstanceBaseUrl(instance), + ); + const resp = await axios.post(reqUrl.href, req); + // FIXME: validate + return resp.data; + } +} + +export interface CreateMerchantTippingReserveRequest { + // Amount that the merchant promises to put into the reserve + initial_balance: AmountString; + + // Exchange the merchant intends to use for tipping + exchange_url: string; + + // Desired wire method, for example "iban" or "x-taler-bank" + wire_method: string; +} + +export interface CreateMerchantTippingReserveConfirmation { + // Public key identifying the reserve + reserve_pub: string; + + // Wire account of the exchange where to transfer the funds + payto_uri: string; +} + +export class MerchantService implements MerchantServiceInterface { + static fromExistingConfig(gc: GlobalTestState, name: string) { + const cfgFilename = gc.testDir + `/merchant-${name}.conf`; + const config = Configuration.load(cfgFilename); + const mc: MerchantConfig = { + currency: config.getString("taler", "currency").required(), + database: config.getString("merchantdb-postgres", "config").required(), + httpPort: config.getNumber("merchant", "port").required(), + name, + }; + return new MerchantService(gc, mc, cfgFilename); + } + + proc: ProcessWrapper | undefined; + + constructor( + private globalState: GlobalTestState, + private merchantConfig: MerchantConfig, + private configFilename: string, + ) {} + + private currentTimetravel: Duration | undefined; + + private isRunning(): boolean { + return !!this.proc; + } + + setTimetravel(t: Duration | undefined): void { + if (this.isRunning()) { + throw Error("can't set time travel while the exchange is running"); + } + this.currentTimetravel = t; + } + + private get timetravelArg(): string | undefined { + if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") { + // Convert to microseconds + return `--timetravel=+${this.currentTimetravel.d_ms * 1000}`; + } + return undefined; + } + + /** + * Return an empty array if no time travel is set, + * and an array with the time travel command line argument + * otherwise. + */ + private get timetravelArgArr(): string[] { + const tta = this.timetravelArg; + if (tta) { + return [tta]; + } + return []; + } + + get port(): number { + return this.merchantConfig.httpPort; + } + + get name(): string { + return this.merchantConfig.name; + } + + async stop(): Promise<void> { + const httpd = this.proc; + if (httpd) { + httpd.proc.kill("SIGTERM"); + await httpd.wait(); + this.proc = undefined; + } + } + + async start(): Promise<void> { + await exec(`taler-merchant-dbinit -c "${this.configFilename}"`); + + this.proc = this.globalState.spawnService( + "taler-merchant-httpd", + ["-LDEBUG", "-c", this.configFilename, ...this.timetravelArgArr], + `merchant-${this.merchantConfig.name}`, + ); + } + + static async create( + gc: GlobalTestState, + mc: MerchantConfig, + ): Promise<MerchantService> { + const config = new Configuration(); + config.setString("taler", "currency", mc.currency); + + const cfgFilename = gc.testDir + `/merchant-${mc.name}.conf`; + setPaths(config, gc.testDir + "/talerhome"); + config.setString("merchant", "serve", "tcp"); + config.setString("merchant", "port", `${mc.httpPort}`); + config.setString( + "merchant", + "keyfile", + "${TALER_DATA_HOME}/merchant/merchant.priv", + ); + config.setString("merchantdb-postgres", "config", mc.database); + config.write(cfgFilename); + + return new MerchantService(gc, mc, cfgFilename); + } + + addExchange(e: ExchangeServiceInterface): void { + const config = Configuration.load(this.configFilename); + config.setString( + `merchant-exchange-${e.name}`, + "exchange_base_url", + e.baseUrl, + ); + config.setString( + `merchant-exchange-${e.name}`, + "currency", + this.merchantConfig.currency, + ); + config.setString(`merchant-exchange-${e.name}`, "master_key", e.masterPub); + config.write(this.configFilename); + } + + async addInstance(instanceConfig: MerchantInstanceConfig): Promise<void> { + if (!this.proc) { + throw Error("merchant must be running to add instance"); + } + console.log("adding instance"); + const url = `http://localhost:${this.merchantConfig.httpPort}/private/instances`; + await axios.post(url, { + payto_uris: instanceConfig.paytoUris, + id: instanceConfig.id, + name: instanceConfig.name, + address: instanceConfig.address ?? {}, + jurisdiction: instanceConfig.jurisdiction ?? {}, + default_max_wire_fee: + instanceConfig.defaultMaxWireFee ?? + `${this.merchantConfig.currency}:1.0`, + default_wire_fee_amortization: + instanceConfig.defaultWireFeeAmortization ?? 3, + default_max_deposit_fee: + instanceConfig.defaultMaxDepositFee ?? + `${this.merchantConfig.currency}:1.0`, + default_wire_transfer_delay: instanceConfig.defaultWireTransferDelay ?? { + d_ms: "forever", + }, + default_pay_delay: instanceConfig.defaultPayDelay ?? { d_ms: "forever" }, + }); + } + + makeInstanceBaseUrl(instanceName?: string): string { + if (instanceName === undefined || instanceName === "default") { + return `http://localhost:${this.merchantConfig.httpPort}/`; + } else { + return `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/`; + } + } + + async pingUntilAvailable(): Promise<void> { + const url = `http://localhost:${this.merchantConfig.httpPort}/config`; + await pingProc(this.proc, url, `merchant (${this.merchantConfig.name})`); + } +} + +export interface MerchantInstanceConfig { + id: string; + name: string; + paytoUris: string[]; + address?: unknown; + jurisdiction?: unknown; + defaultMaxWireFee?: string; + defaultMaxDepositFee?: string; + defaultWireFeeAmortization?: number; + defaultWireTransferDelay?: Duration; + defaultPayDelay?: Duration; +} + +type TestStatus = "pass" | "fail" | "skip"; + +export interface TestRunResult { + /** + * Name of the test. + */ + name: string; + + /** + * How long did the test run? + */ + timeSec: number; + + status: TestStatus; +} + +export async function runTestWithState( + gc: GlobalTestState, + testMain: (t: GlobalTestState) => Promise<void>, + testName: string, +): Promise<TestRunResult> { + const startMs = new Date().getTime(); + + const handleSignal = () => { + gc.shutdownSync(); + console.warn("**** received fatal signal, shutting down test harness"); + process.exit(1); + }; + + process.on("SIGINT", handleSignal); + process.on("SIGTERM", handleSignal); + process.on("unhandledRejection", handleSignal); + process.on("uncaughtException", handleSignal); + + let status: TestStatus; + try { + console.log("running test in directory", gc.testDir); + await testMain(gc); + status = "pass"; + } catch (e) { + 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 class WalletCli { + private currentTimetravel: Duration | undefined; + + 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", + ) {} + + 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 []; + } + + async apiRequest( + request: string, + payload: unknown, + ): Promise<CoreApiResponse> { + const resp = await sh( + this.globalTestState, + `wallet-${this.name}`, + `taler-wallet-cli ${ + this.timetravelArg ?? "" + } --no-throttle --wallet-db '${this.dbfile}' api '${request}' ${shellWrap( + JSON.stringify(payload), + )}`, + ); + console.log(resp); + return JSON.parse(resp) as CoreApiResponse; + } + + async runUntilDone(args: { maxRetries?: number } = {}): Promise<void> { + await runCommand( + this.globalTestState, + `wallet-${this.name}`, + "taler-wallet-cli", + [ + "--no-throttle", + ...this.timetravelArgArr, + "--wallet-db", + this.dbfile, + "run-until-done", + ...(args.maxRetries ? ["--max-retries", `${args.maxRetries}`] : []), + ], + ); + } + + async runPending(): Promise<void> { + await runCommand( + this.globalTestState, + `wallet-${this.name}`, + "taler-wallet-cli", + [ + "--no-throttle", + ...this.timetravelArgArr, + "--wallet-db", + this.dbfile, + "run-pending", + ], + ); + } + + async applyRefund(req: ApplyRefundRequest): Promise<ApplyRefundResponse> { + const resp = await this.apiRequest("applyRefund", req); + if (resp.type === "response") { + return codecForApplyRefundResponse().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } + + async preparePay(req: PreparePayRequest): Promise<PreparePayResult> { + const resp = await this.apiRequest("preparePay", req); + if (resp.type === "response") { + return codecForPreparePayResult().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } + + async abortFailedPayWithRefund( + req: AbortPayWithRefundRequest, + ): Promise<void> { + const resp = await this.apiRequest("abortFailedPayWithRefund", req); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + + async confirmPay(req: ConfirmPayRequest): Promise<ConfirmPayResult> { + const resp = await this.apiRequest("confirmPay", req); + if (resp.type === "response") { + return codecForConfirmPayResult().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } + + async prepareTip(req: PrepareTipRequest): Promise<PrepareTipResult> { + const resp = await this.apiRequest("prepareTip", req); + if (resp.type === "response") { + return codecForPrepareTipResult().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } + + async acceptTip(req: AcceptTipRequest): Promise<void> { + const resp = await this.apiRequest("acceptTip", req); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + + async dumpCoins(): Promise<CoinDumpJson> { + const resp = await this.apiRequest("dumpCoins", {}); + if (resp.type === "response") { + return codecForAny().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } + + async addExchange(req: AddExchangeRequest): Promise<void> { + const resp = await this.apiRequest("addExchange", req); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + + async forceUpdateExchange(req: ForceExchangeUpdateRequest): Promise<void> { + const resp = await this.apiRequest("forceUpdateExchange", req); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + + async forceRefresh(req: ForceRefreshRequest): Promise<void> { + const resp = await this.apiRequest("forceRefresh", req); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + + async listExchanges(): Promise<ExchangesListRespose> { + const resp = await this.apiRequest("listExchanges", {}); + if (resp.type === "response") { + return codecForExchangesListResponse().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } + + async getBalances(): Promise<BalancesResponse> { + const resp = await this.apiRequest("getBalances", {}); + if (resp.type === "response") { + return codecForBalancesResponse().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } + + async getPendingOperations(): Promise<PendingOperationsResponse> { + const resp = await this.apiRequest("getPendingOperations", {}); + if (resp.type === "response") { + // FIXME: validate properly! + return codecForAny().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } + + async getTransactions(): Promise<TransactionsResponse> { + const resp = await this.apiRequest("getTransactions", {}); + if (resp.type === "response") { + return codecForTransactionsResponse().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } + + async runIntegrationTest(args: IntegrationTestArgs): Promise<void> { + const resp = await this.apiRequest("runIntegrationTest", args); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + + async testPay(args: TestPayArgs): Promise<void> { + const resp = await this.apiRequest("testPay", args); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + + async withdrawTestBalance(args: WithdrawTestBalanceRequest): Promise<void> { + const resp = await this.apiRequest("withdrawTestBalance", args); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + + async getWithdrawalDetailsForUri( + req: GetWithdrawalDetailsForUriRequest, + ): Promise<WithdrawUriInfoResponse> { + const resp = await this.apiRequest("getWithdrawalDetailsForUri", req); + if (resp.type === "response") { + return codecForWithdrawUriInfoResponse().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } +} |