/* 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 */ /** * Test harness for various GNU Taler components. * Also provides a fault-injection proxy. * * @author Florian Dold */ /** * Imports */ import { AccountAddDetails, AccountRestriction, AmountJson, Amounts, Configuration, CoreApiResponse, Duration, EddsaKeyPair, Logger, MerchantInstanceConfig, PartialMerchantInstanceConfig, TalerCorebankApiClient, TalerError, 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 { 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 { 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 { 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; 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 { 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, ): Promise { 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): Promise { 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(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 { 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; } export interface TalerConfig { sections: Record; } export interface DbInfo { /** * Postgres connection string. */ connStr: string; dbname: string; } export interface SetupDbOpts { nameSuffix?: string; } export async function setupDb( t: GlobalTestState, opts: SetupDbOpts = {}, ): Promise { 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 { 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 { 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 { 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.write(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.write(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 { 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 { 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 { 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 { 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", "DEFAULT_DEBT_LIMIT", `${bc.currency}:100`, ); config.setString( "libeufin-bank", "registration_bonus", `${bc.currency}:100`, ); const cfgFilename = testDir + "/bank.conf"; config.write(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.write(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 { 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 { const url = `http://localhost:${this.bankConfig.httpPort}/config`; await pingProc(this.proc, url, "libeufin-bank"); } } // Use libeufin bank instead of pybank. const useLibeufinBank = false; export interface BankServiceHandle { readonly corebankApiBaseUrl: string; readonly http: HttpRequestLibrary; } export type BankService = BankServiceHandle; export const BankService = 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.write(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.write(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.write(this.configFilename, { excludeDefaults: true }); } addCoinConfigList(ccs: CoinConfig[]) { const config = Configuration.load(this.configFilename); ccs.forEach((cc) => setCoin(config, cc)); config.write(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.write(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, ): Promise { const config = Configuration.load(this.configFilename); await f(config); config.write(this.configFilename, { excludeDefaults: true }); } async addBankAccount( localName: string, exchangeBankAccount: HarnessExchangeBankAccount, ): Promise { 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.write(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 { const wirewatch = this.exchangeWirewatchProc; if (wirewatch) { wirewatch.proc.kill("SIGTERM"); await wirewatch.wait(); this.exchangeWirewatchProc = undefined; } } async stopAggregator(): Promise { const agg = this.exchangeAggregatorProc; if (agg) { agg.proc.kill("SIGTERM"); await agg.wait(); this.exchangeAggregatorProc = undefined; } } async startWirewatch(): Promise { const wirewatch = this.exchangeWirewatchProc; if (wirewatch) { logger.warn("wirewatch already running"); } else { this.internalCreateWirewatchProc(); } } async stop(): Promise { 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 { await runCommand( this.globalState, "exchange-offline", "taler-exchange-offline", ["-c", this.configFilename, "download", "sign", "upload"], ); const accountTargetTypes: Set = 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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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.write(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.write(this.configFilename, { excludeDefaults: true }); } async addDefaultInstance(): Promise { 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 { 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: AccountAddDetails = { payto_uri: paytoUri, }; 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 { 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, testName: string, linger: boolean = false, ): Promise { 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((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 { 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 { if (this.walletProc) { this.walletProc.proc.kill("SIGTERM"); await this.walletProc.wait(); } } async start(): Promise { 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 { 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( operation: Op, payload: WalletCoreRequestType, ): Promise> { 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 { 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( cond: (n: WalletNotification) => T | undefined | false, ): Promise { 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 { 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 { 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 { 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 { if (useLibeufinBank) return "iban"; return "x-taler-bank"; } /** * Generate a payto address, whose authority depends * on whether the banking is served by euFin or Pybank. */ export function generateRandomPayto(label: string): string { if (useLibeufinBank) return `payto://iban/SANDBOXX/${generateRandomTestIban( label, )}?receiver-name=${label}`; return `payto://x-taler-bank/localhost/${label}?receiver-name=${label}`; } function waitMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }