diff options
Diffstat (limited to 'packages/taler-harness/src/harness/harness.ts')
-rw-r--r-- | packages/taler-harness/src/harness/harness.ts | 2255 |
1 files changed, 2255 insertions, 0 deletions
diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts new file mode 100644 index 000000000..68c0744fc --- /dev/null +++ b/packages/taler-harness/src/harness/harness.ts @@ -0,0 +1,2255 @@ +/* + 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 { + 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<void> { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(), ms); + }); +} + +export interface WithAuthorization { + Authorization?: string; +} + +interface WaitResult { + code: number | null; + signal: NodeJS.Signals | null; +} + +class CommandError extends Error { + constructor( + public message: string, + public logName: string, + public command: string, + public args: string[], + public env: Env, + public code: number | null, + ) { + super(message); + } +} +interface Env { + [index: string]: string | undefined; +} +/** + * Run a shell command, return stdout. + */ +export async function sh( + t: GlobalTestState, + logName: string, + command: string, + env: Env = process.env, +): Promise<string> { + logger.trace(`running command ${command}`); + return new Promise((resolve, reject) => { + const stdoutChunks: Buffer[] = []; + const proc = spawn(command, { + stdio: ["inherit", "pipe", "pipe"], + shell: true, + env, + }); + proc.stdout.on("data", (x) => { + if (x instanceof Buffer) { + stdoutChunks.push(x); + } else { + throw Error("unexpected data chunk type"); + } + }); + const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`); + const stderrLog = fs.createWriteStream(stderrLogFileName, { + flags: "a", + }); + proc.stderr.pipe(stderrLog); + proc.on("exit", (code, signal) => { + logger.info(`child process ${logName} exited (${code} / ${signal})`); + if (code != 0) { + reject( + new CommandError( + `Unexpected exit code ${code}`, + logName, + command, + [], + env, + code, + ), + ); + return; + } + const b = Buffer.concat(stdoutChunks).toString("utf-8"); + resolve(b); + }); + proc.on("error", (err) => { + reject( + new CommandError( + "Child process had error:" + err.message, + logName, + command, + [], + env, + null, + ), + ); + }); + }); +} + +function shellescape(args: string[]) { + const ret = args.map((s) => { + if (/[^A-Za-z0-9_\/:=-]/.test(s)) { + s = "'" + s.replace(/'/g, "'\\''") + "'"; + s = s.replace(/^(?:'')+/g, "").replace(/\\'''/g, "\\'"); + } + return s; + }); + return ret.join(" "); +} + +/** + * Run a shell command, return stdout. + * + * Log stderr to a log file. + */ +export async function runCommand( + t: GlobalTestState, + logName: string, + command: string, + args: string[], + env: { [index: string]: string | undefined } = process.env, +): Promise<string> { + logger.info(`running command ${shellescape([command, ...args])}`); + + return new Promise((resolve, reject) => { + const stdoutChunks: Buffer[] = []; + const proc = spawn(command, args, { + stdio: ["inherit", "pipe", "pipe"], + shell: false, + env: env, + }); + proc.stdout.on("data", (x) => { + if (x instanceof Buffer) { + stdoutChunks.push(x); + } else { + throw Error("unexpected data chunk type"); + } + }); + const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`); + const stderrLog = fs.createWriteStream(stderrLogFileName, { + flags: "a", + }); + proc.stderr.pipe(stderrLog); + proc.on("exit", (code, signal) => { + logger.trace(`child process exited (${code} / ${signal})`); + if (code != 0) { + reject( + new CommandError( + `Unexpected exit code ${code}`, + logName, + command, + [], + env, + code, + ), + ); + return; + } + const b = Buffer.concat(stdoutChunks).toString("utf-8"); + resolve(b); + }); + proc.on("error", (err) => { + reject( + new CommandError( + "Child process had error:" + err.message, + logName, + command, + [], + env, + null, + ), + ); + }); + }); +} + +export class ProcessWrapper { + private waitPromise: Promise<WaitResult>; + constructor(public proc: ChildProcess) { + this.waitPromise = new Promise((resolve, reject) => { + proc.on("exit", (code, signal) => { + resolve({ code, signal }); + }); + proc.on("error", (err) => { + reject(err); + }); + }); + } + + wait(): Promise<WaitResult> { + return this.waitPromise; + } +} + +export class GlobalTestParams { + testDir: string; +} + +export class GlobalTestState { + testDir: string; + procs: ProcessWrapper[]; + servers: http.Server[]; + inShutdown: boolean = false; + constructor(params: GlobalTestParams) { + this.testDir = params.testDir; + this.procs = []; + this.servers = []; + } + + async assertThrowsTalerErrorAsync( + block: () => Promise<void>, + ): Promise<TalerError> { + try { + await block(); + } catch (e) { + if (e instanceof TalerError) { + return e; + } + throw Error(`expected TalerError to be thrown, but got ${e}`); + } + throw Error( + `expected TalerError to be thrown, but block finished without throwing`, + ); + } + + async assertThrowsAsync(block: () => Promise<void>): Promise<any> { + try { + await block(); + } catch (e) { + return e; + } + throw Error( + `expected exception to be thrown, but block finished without throwing`, + ); + } + + assertTrue(b: boolean): asserts b { + if (!b) { + throw Error("test assertion failed"); + } + } + + assertDeepEqual<T>(actual: any, expected: T): asserts actual is T { + deepStrictEqual(actual, expected); + } + + assertAmountEquals( + amtActual: string | AmountJson, + amtExpected: string | AmountJson, + ): void { + if (Amounts.cmp(amtActual, amtExpected) != 0) { + throw Error( + `test assertion failed: expected ${Amounts.stringify( + amtExpected, + )} but got ${Amounts.stringify(amtActual)}`, + ); + } + } + + assertAmountLeq(a: string | AmountJson, b: string | AmountJson): void { + if (Amounts.cmp(a, b) > 0) { + throw Error( + `test assertion failed: expected ${Amounts.stringify( + a, + )} to be less or equal (leq) than ${Amounts.stringify(b)}`, + ); + } + } + + shutdownSync(): void { + for (const s of this.servers) { + s.close(); + s.removeAllListeners(); + } + for (const p of this.procs) { + if (p.proc.exitCode == null) { + p.proc.kill("SIGTERM"); + } + } + } + + spawnService( + command: string, + args: string[], + logName: string, + env: { [index: string]: string | undefined } = process.env, + ): ProcessWrapper { + logger.info( + `spawning process (${logName}): ${shellescape([command, ...args])}`, + ); + const proc = spawn(command, args, { + stdio: ["inherit", "pipe", "pipe"], + env: env, + }); + logger.trace(`spawned process (${logName}) with pid ${proc.pid}`); + proc.on("error", (err) => { + logger.warn(`could not start process (${command})`, err); + }); + proc.on("exit", (code, signal) => { + if (code == 0 && signal == null) { + logger.trace(`process ${logName} exited with success`); + } else { + logger.warn(`process ${logName} exited ${j2s({ code, signal })}`); + } + }); + const stderrLogFileName = this.testDir + `/${logName}-stderr.log`; + const stderrLog = fs.createWriteStream(stderrLogFileName, { + flags: "a", + }); + proc.stderr.pipe(stderrLog); + const stdoutLogFileName = this.testDir + `/${logName}-stdout.log`; + const stdoutLog = fs.createWriteStream(stdoutLogFileName, { + flags: "a", + }); + proc.stdout.pipe(stdoutLog); + const procWrap = new ProcessWrapper(proc); + this.procs.push(procWrap); + return procWrap; + } + + async shutdown(): Promise<void> { + if (this.inShutdown) { + return; + } + if (shouldLingerInTest()) { + logger.trace("refusing to shut down, lingering was requested"); + return; + } + this.inShutdown = true; + logger.trace("shutting down"); + for (const s of this.servers) { + s.close(); + s.removeAllListeners(); + } + for (const p of this.procs) { + if (p.proc.exitCode == null) { + logger.trace(`killing process ${p.proc.pid}`); + p.proc.kill("SIGTERM"); + await p.wait(); + } + } + } + + /** + * Log that the test arrived a certain step. + * + * The step name should be unique across the whole + */ + logStep(stepName: string): void { + // Now we just log, later we may report the steps that were done + // to easily see where the test hangs. + console.info(`STEP: ${stepName}`); + } +} + +export function shouldLingerInTest(): boolean { + return !!process.env["TALER_TEST_LINGER"]; +} + +export interface TalerConfigSection { + options: Record<string, string | undefined>; +} + +export interface TalerConfig { + sections: Record<string, TalerConfigSection>; +} + +export interface DbInfo { + /** + * Postgres connection string. + */ + connStr: string; + + dbname: string; +} + +export interface SetupDbOpts { + nameSuffix?: string; +} + +export async function setupDb( + t: GlobalTestState, + opts: SetupDbOpts = {}, +): Promise<DbInfo> { + let dbname: string; + if (!opts.nameSuffix) { + dbname = "taler-integrationtest"; + } else { + dbname = `taler-integrationtest-${opts.nameSuffix}`; + } + try { + await runCommand(t, "dropdb", "dropdb", [dbname]); + } catch (e: any) { + logger.warn(`dropdb failed: ${e.toString()}`); + } + await runCommand(t, "createdb", "createdb", [dbname]); + return { + connStr: `postgres:///${dbname}`, + dbname, + }; +} + +/** + * Make sure that the taler-integrationtest-shared database exists. + * Don't delete it if it already exists. + */ +export async function setupSharedDb(t: GlobalTestState): Promise<DbInfo> { + const dbname = "taler-integrationtest-shared"; + const databases = await runCommand(t, "list-dbs", "psql", ["-Aqtl"]); + if (databases.indexOf("taler-integrationtest-shared") < 0) { + await runCommand(t, "createdb", "createdb", [dbname]); + } + return { + connStr: `postgres:///${dbname}`, + dbname, + }; +} + +export interface BankConfig { + currency: string; + httpPort: number; + database: string; + allowRegistrations: boolean; + maxDebt?: string; + overrideTestDir?: string; +} + +export interface FakeBankConfig { + currency: string; + httpPort: number; +} + +/** + * @param name additional component name, needed when launching multiple instances of the same component + */ +function setTalerPaths(config: Configuration, home: string, name?: string) { + config.setString("paths", "taler_home", home); + // We need to make sure that the path of taler_runtime_dir isn't too long, + // as it contains unix domain sockets (108 character limit). + const extraName = name != null ? `${name}-` : ""; + const runDir = fs.mkdtempSync(`/tmp/taler-test-${extraName}`); + config.setString("paths", "taler_runtime_dir", runDir); + config.setString( + "paths", + "taler_data_home", + "$TALER_HOME/.local/share/taler/", + ); + config.setString("paths", "taler_config_home", "$TALER_HOME/.config/taler/"); + config.setString("paths", "taler_cache_home", "$TALER_HOME/.config/taler/"); +} + +function setCoin(config: Configuration, c: CoinConfig) { + const s = `coin_${c.name}`; + config.setString(s, "value", c.value); + config.setString(s, "duration_withdraw", c.durationWithdraw); + config.setString(s, "duration_spend", c.durationSpend); + config.setString(s, "duration_legal", c.durationLegal); + config.setString(s, "fee_deposit", c.feeDeposit); + config.setString(s, "fee_withdraw", c.feeWithdraw); + config.setString(s, "fee_refresh", c.feeRefresh); + config.setString(s, "fee_refund", c.feeRefund); + if (c.ageRestricted) { + config.setString(s, "age_restricted", "yes"); + } + if (c.cipher === "RSA") { + config.setString(s, "rsa_keysize", `${c.rsaKeySize}`); + config.setString(s, "cipher", "RSA"); + } else if (c.cipher === "CS") { + config.setString(s, "cipher", "CS"); + } else { + throw new Error(); + } +} + +function backoffStart(): number { + return 10; +} + +function backoffIncrement(n: number): number { + return Math.min(Math.floor(n * 1.5), 1000); +} + +/** + * Send an HTTP request until it succeeds or the process dies. + */ +export async function pingProc( + proc: ProcessWrapper | undefined, + url: string, + serviceName: string, +): Promise<void> { + if (!proc || proc.proc.exitCode !== null) { + throw Error(`service process ${serviceName} not started, can't ping`); + } + let nextDelay = backoffStart(); + while (true) { + try { + logger.trace(`pinging ${serviceName} at ${url}`); + const resp = await harnessHttpLib.fetch(url); + if (resp.status !== 200) { + throw Error("non-200 status code"); + } + logger.trace(`service ${serviceName} available`); + return; + } catch (e: any) { + logger.warn(`service ${serviceName} not ready:`, e.toString()); + logger.info(`waiting ${nextDelay}ms on ${serviceName}`); + await delayMs(nextDelay); + nextDelay = backoffIncrement(nextDelay); + } + if (!proc || proc.proc.exitCode != null || proc.proc.signalCode != null) { + throw Error(`service process ${serviceName} stopped unexpectedly`); + } + } +} + +class BankServiceBase { + proc: ProcessWrapper | undefined; + + protected constructor( + protected globalTestState: GlobalTestState, + protected bankConfig: BankConfig, + protected configFile: string, + ) {} +} + +export interface HarnessExchangeBankAccount { + accountName: string; + accountPassword: string; + accountPaytoUri: string; + wireGatewayApiBaseUrl: string; + + conversionUrl?: string; + + debitRestrictions?: AccountRestriction[]; + creditRestrictions?: AccountRestriction[]; + + /** + * If set, the harness will not automatically configure the wire fee for this account. + */ + skipWireFeeCreation?: boolean; +} + +/** + * Implementation of the bank service using the "taler-fakebank-run" tool. + */ +export class FakebankService + extends BankServiceBase + implements BankServiceHandle +{ + proc: ProcessWrapper | undefined; + + http = createPlatformHttpLib({ enableThrottling: false }); + + // We store "created" accounts during setup and + // register them after startup. + private accounts: { + accountName: string; + accountPassword: string; + }[] = []; + + /** + * Create a new fakebank service handle. + * + * First generates the configuration for the fakebank and + * then creates a fakebank handle, but doesn't start the fakebank + * service yet. + */ + static async create( + gc: GlobalTestState, + bc: BankConfig, + ): Promise<FakebankService> { + const config = new Configuration(); + const testDir = bc.overrideTestDir ?? gc.testDir; + setTalerPaths(config, testDir + "/talerhome"); + config.setString("taler", "currency", bc.currency); + config.setString("bank", "http_port", `${bc.httpPort}`); + config.setString("bank", "serve", "http"); + config.setString("bank", "max_debt_bank", `${bc.currency}:999999`); + config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`); + config.setString("bank", "ram_limit", `${1024}`); + const cfgFilename = testDir + "/bank.conf"; + config.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<HarnessExchangeBankAccount> { + this.accounts.push({ + accountName, + accountPassword: password, + }); + return { + accountName: accountName, + accountPassword: password, + accountPaytoUri: generateRandomPayto(accountName), + wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/accounts/${accountName}/taler-wire-gateway/`, + }; + } + + get port() { + return this.bankConfig.httpPort; + } + + async start(): Promise<void> { + logger.info("starting fakebank"); + if (this.proc) { + logger.info("fakebank already running, not starting again"); + return; + } + this.proc = this.globalTestState.spawnService( + "taler-fakebank-run", + [ + "-c", + this.configFile, + "--signup-bonus", + `${this.bankConfig.currency}:100`, + ], + "bank", + ); + await this.pingUntilAvailable(); + const bankClient = new TalerCorebankApiClient(this.corebankApiBaseUrl); + for (const acc of this.accounts) { + await bankClient.registerAccount(acc.accountName, acc.accountPassword); + } + } + + async pingUntilAvailable(): Promise<void> { + const url = `http://localhost:${this.bankConfig.httpPort}/config`; + await pingProc(this.proc, url, "bank"); + } +} + +/** + * Implementation of the bank service using the libeufin-bank implementation. + */ +export class LibeufinBankService + extends BankServiceBase + implements BankServiceHandle +{ + proc: ProcessWrapper | undefined; + + http = createPlatformHttpLib({ enableThrottling: false }); + + // We store "created" accounts during setup and + // register them after startup. + private accounts: { + accountName: string; + accountPassword: string; + }[] = []; + + /** + * Create a new fakebank service handle. + * + * First generates the configuration for the fakebank and + * then creates a fakebank handle, but doesn't start the fakebank + * service yet. + */ + static async create( + gc: GlobalTestState, + bc: BankConfig, + ): Promise<LibeufinBankService> { + const config = new Configuration(); + const testDir = bc.overrideTestDir ?? gc.testDir; + setTalerPaths(config, testDir + "/talerhome"); + config.setString("libeufin-bankdb-postgres", "config", bc.database); + config.setString("libeufin-bank", "currency", bc.currency); + config.setString("libeufin-bank", "port", `${bc.httpPort}`); + config.setString("libeufin-bank", "serve", "tcp"); + config.setString( + "libeufin-bank", + "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<void> { + logger.info("starting libeufin-bank"); + if (this.proc) { + logger.info("libeufin-bank already running, not starting again"); + return; + } + + await sh( + this.globalTestState, + "libeufin-bank-dbinit", + `libeufin-bank dbinit -r -c "${this.configFile}"`, + ); + + await sh( + this.globalTestState, + "libeufin-bank-passwd", + `libeufin-bank passwd -c "${this.configFile}" admin adminpw`, + ); + + await sh( + this.globalTestState, + "libeufin-bank-edit-account", + `libeufin-bank edit-account -c "${this.configFile}" admin --debit_threshold=${this.bankConfig.currency}:1000`, + ); + + this.proc = this.globalTestState.spawnService( + "libeufin-bank", + ["serve", "-c", this.configFile], + "libeufin-bank-httpd", + ); + await this.pingUntilAvailable(); + const bankClient = new TalerCorebankApiClient(this.corebankApiBaseUrl); + for (const acc of this.accounts) { + await bankClient.registerAccount(acc.accountName, acc.accountPassword); + } + } + + async pingUntilAvailable(): Promise<void> { + const url = `http://localhost:${this.bankConfig.httpPort}/config`; + await pingProc(this.proc, url, "libeufin-bank"); + } +} + +// Use libeufin bank instead of pybank. +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<void>, + ): Promise<void> { + const config = Configuration.load(this.configFilename); + await f(config); + config.write(this.configFilename, { excludeDefaults: true }); + } + + async addBankAccount( + localName: string, + exchangeBankAccount: HarnessExchangeBankAccount, + ): Promise<void> { + this.exchangeBankAccounts.push(exchangeBankAccount); + const config = Configuration.load(this.configFilename); + config.setString( + `exchange-account-${localName}`, + "wire_response", + `\${TALER_DATA_HOME}/exchange/account-${localName}.json`, + ); + config.setString( + `exchange-account-${localName}`, + "payto_uri", + exchangeBankAccount.accountPaytoUri, + ); + config.setString(`exchange-account-${localName}`, "enable_credit", "yes"); + config.setString(`exchange-account-${localName}`, "enable_debit", "yes"); + config.setString( + `exchange-accountcredentials-${localName}`, + "wire_gateway_url", + exchangeBankAccount.wireGatewayApiBaseUrl, + ); + config.setString( + `exchange-accountcredentials-${localName}`, + "wire_gateway_auth_method", + "basic", + ); + config.setString( + `exchange-accountcredentials-${localName}`, + "username", + exchangeBankAccount.accountName, + ); + config.setString( + `exchange-accountcredentials-${localName}`, + "password", + exchangeBankAccount.accountPassword, + ); + config.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<void> { + const wirewatch = this.exchangeWirewatchProc; + if (wirewatch) { + wirewatch.proc.kill("SIGTERM"); + await wirewatch.wait(); + this.exchangeWirewatchProc = undefined; + } + } + + async stopAggregator(): Promise<void> { + const agg = this.exchangeAggregatorProc; + if (agg) { + agg.proc.kill("SIGTERM"); + await agg.wait(); + this.exchangeAggregatorProc = undefined; + } + } + + async startWirewatch(): Promise<void> { + const wirewatch = this.exchangeWirewatchProc; + if (wirewatch) { + logger.warn("wirewatch already running"); + } else { + this.internalCreateWirewatchProc(); + } + } + + async stop(): Promise<void> { + const wirewatch = this.exchangeWirewatchProc; + if (wirewatch) { + wirewatch.proc.kill("SIGTERM"); + await wirewatch.wait(); + this.exchangeWirewatchProc = undefined; + } + const aggregatorProc = this.exchangeAggregatorProc; + if (aggregatorProc) { + aggregatorProc.proc.kill("SIGTERM"); + await aggregatorProc.wait(); + this.exchangeAggregatorProc = undefined; + } + const transferProc = this.exchangeTransferProc; + if (transferProc) { + transferProc.proc.kill("SIGTERM"); + await transferProc.wait(); + this.exchangeTransferProc = undefined; + } + const httpd = this.exchangeHttpProc; + if (httpd) { + httpd.proc.kill("SIGTERM"); + await httpd.wait(); + this.exchangeHttpProc = undefined; + } + const cryptoRsa = this.helperCryptoRsaProc; + if (cryptoRsa) { + cryptoRsa.proc.kill("SIGTERM"); + await cryptoRsa.wait(); + this.helperCryptoRsaProc = undefined; + } + const cryptoEddsa = this.helperCryptoEddsaProc; + if (cryptoEddsa) { + cryptoEddsa.proc.kill("SIGTERM"); + await cryptoEddsa.wait(); + this.helperCryptoRsaProc = undefined; + } + const cryptoCs = this.helperCryptoCsProc; + if (cryptoCs) { + cryptoCs.proc.kill("SIGTERM"); + await cryptoCs.wait(); + this.helperCryptoCsProc = undefined; + } + } + + /** + * Update keys signing the keys generated by the security module + * with the offline signing key. + */ + async keyup(): Promise<void> { + await runCommand( + this.globalState, + "exchange-offline", + "taler-exchange-offline", + ["-c", this.configFilename, "download", "sign", "upload"], + ); + + const accountTargetTypes: Set<string> = new Set(); + + for (const acct of this.exchangeBankAccounts) { + const paytoUri = acct.accountPaytoUri; + const p = parsePaytoUri(paytoUri); + if (!p) { + throw Error(`invalid payto uri in exchange config: ${paytoUri}`); + } + const optArgs: string[] = []; + if (acct.conversionUrl != null) { + optArgs.push("conversion-url", acct.conversionUrl); + } + + await runCommand( + this.globalState, + "exchange-offline", + "taler-exchange-offline", + [ + "-c", + this.configFilename, + "enable-account", + paytoUri, + ...optArgs, + "upload", + ], + ); + + const accTargetType = p.targetType; + + const covered = accountTargetTypes.has(p.targetType); + if (!covered && !acct.skipWireFeeCreation) { + const year = new Date().getFullYear(); + + for (let i = year; i < year + 5; i++) { + const wireFee = + this.exchangeConfig.overrideWireFee ?? + `${this.exchangeConfig.currency}:0.01`; + await runCommand( + this.globalState, + "exchange-offline", + "taler-exchange-offline", + [ + "-c", + this.configFilename, + "wire-fee", + // Year + `${i}`, + // Wire method + accTargetType, + // Wire fee + wireFee, + // Closing fee + `${this.exchangeConfig.currency}:0.01`, + "upload", + ], + ); + accountTargetTypes.add(accTargetType); + } + } + } + + await runCommand( + this.globalState, + "exchange-offline", + "taler-exchange-offline", + [ + "-c", + this.configFilename, + "global-fee", + // year + "now", + // history fee + `${this.exchangeConfig.currency}:0.01`, + // account fee + `${this.exchangeConfig.currency}:0.01`, + // purse fee + `${this.exchangeConfig.currency}:0.00`, + // purse timeout + "1h", + // history expiration + "1year", + // free purses per account + "5", + "upload", + ], + ); + } + + async revokeDenomination(denomPubHash: string) { + if (!this.isRunning()) { + throw Error("exchange must be running when revoking denominations"); + } + await runCommand( + this.globalState, + "exchange-offline", + "taler-exchange-offline", + [ + "-c", + this.configFilename, + "revoke-denomination", + denomPubHash, + "upload", + ], + ); + } + + async purgeSecmodKeys(): Promise<void> { + const cfg = Configuration.load(this.configFilename); + const rsaKeydir = cfg + .getPath("taler-exchange-secmod-rsa", "KEY_DIR") + .required(); + const eddsaKeydir = cfg + .getPath("taler-exchange-secmod-eddsa", "KEY_DIR") + .required(); + // Be *VERY* careful when changing this, or you will accidentally delete user data. + await sh(this.globalState, "rm-secmod-keys", `rm -rf ${rsaKeydir}/COIN_*`); + await sh(this.globalState, "rm-secmod-keys", `rm ${eddsaKeydir}/*`); + } + + async purgeDatabase(): Promise<void> { + await sh( + this.globalState, + "exchange-dbinit", + `taler-exchange-dbinit -r -c "${this.configFilename}"`, + ); + } + + private internalCreateWirewatchProc() { + this.exchangeWirewatchProc = this.globalState.spawnService( + "taler-exchange-wirewatch", + [ + "-c", + this.configFilename, + "--longpoll-timeout=5s", + ...this.timetravelArgArr, + ], + `exchange-wirewatch-${this.name}`, + ); + } + + private internalCreateAggregatorProc() { + this.exchangeAggregatorProc = this.globalState.spawnService( + "taler-exchange-aggregator", + ["-c", this.configFilename, ...this.timetravelArgArr], + `exchange-aggregator-${this.name}`, + ); + } + + private internalCreateTransferProc() { + this.exchangeTransferProc = this.globalState.spawnService( + "taler-exchange-transfer", + ["-c", this.configFilename, ...this.timetravelArgArr], + `exchange-transfer-${this.name}`, + ); + } + + async dbinit() { + await sh( + this.globalState, + "exchange-dbinit", + `taler-exchange-dbinit -c "${this.configFilename}"`, + ); + } + + async start( + opts: { skipDbinit?: boolean; skipKeyup?: boolean } = {}, + ): Promise<void> { + if (this.isRunning()) { + throw Error("exchange is already running"); + } + + const skipDbinit = opts.skipDbinit ?? false; + + if (!skipDbinit) { + await this.dbinit(); + } + + this.helperCryptoEddsaProc = this.globalState.spawnService( + "taler-exchange-secmod-eddsa", + ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr], + `exchange-crypto-eddsa-${this.name}`, + ); + + this.helperCryptoCsProc = this.globalState.spawnService( + "taler-exchange-secmod-cs", + ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr], + `exchange-crypto-cs-${this.name}`, + ); + + this.helperCryptoRsaProc = this.globalState.spawnService( + "taler-exchange-secmod-rsa", + ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr], + `exchange-crypto-rsa-${this.name}`, + ); + + this.internalCreateWirewatchProc(); + this.internalCreateTransferProc(); + this.internalCreateAggregatorProc(); + + this.exchangeHttpProc = this.globalState.spawnService( + "taler-exchange-httpd", + ["-LINFO", "-c", this.configFilename, ...this.timetravelArgArr], + `exchange-httpd-${this.name}`, + ); + + await this.pingUntilAvailable(); + + const skipKeyup = opts.skipKeyup ?? false; + + if (!skipKeyup) { + await this.keyup(); + } else { + logger.info("skipping keyup"); + } + } + + async pingUntilAvailable(): Promise<void> { + // We request /management/keys, since /keys can block + // when we didn't do the key setup yet. + const url = `http://localhost:${this.exchangeConfig.httpPort}/management/keys`; + await pingProc(this.exchangeHttpProc, url, `exchange (${this.name})`); + } +} + +export interface MerchantConfig { + name: string; + currency: string; + httpPort: number; + database: string; + overrideTestDir?: string; +} + +export interface MerchantServiceInterface { + makeInstanceBaseUrl(instanceName?: string): string; + readonly port: number; + readonly name: string; +} + +/** + * Default HTTP client handle for the integration test harness. + */ +export const harnessHttpLib = createPlatformHttpLib({ + enableThrottling: false, +}); + +export class MerchantService implements MerchantServiceInterface { + static fromExistingConfig( + gc: GlobalTestState, + name: string, + opts: { overridePath?: string }, + ) { + const testDir = opts.overridePath ?? gc.testDir; + const cfgFilename = testDir + `/merchant-${name}.conf`; + const config = Configuration.load(cfgFilename); + const mc: MerchantConfig = { + currency: config.getString("taler", "currency").required(), + database: config.getString("merchantdb-postgres", "config").required(), + httpPort: config.getNumber("merchant", "port").required(), + name, + }; + return new MerchantService(gc, mc, cfgFilename); + } + + proc: ProcessWrapper | undefined; + + constructor( + private globalState: GlobalTestState, + private merchantConfig: MerchantConfig, + private configFilename: string, + ) {} + + private currentTimetravelOffsetMs: number | undefined; + + private isRunning(): boolean { + return !!this.proc; + } + + setTimetravel(t: number | undefined): void { + if (this.isRunning()) { + throw Error("can't set time travel while the exchange is running"); + } + this.currentTimetravelOffsetMs = t; + } + + private get timetravelArg(): string | undefined { + if (this.currentTimetravelOffsetMs != null) { + // Convert to microseconds + return `--timetravel=+${this.currentTimetravelOffsetMs * 1000}`; + } + return undefined; + } + + /** + * Return an empty array if no time travel is set, + * and an array with the time travel command line argument + * otherwise. + */ + private get timetravelArgArr(): string[] { + const tta = this.timetravelArg; + if (tta) { + return [tta]; + } + return []; + } + + get port(): number { + return this.merchantConfig.httpPort; + } + + get name(): string { + return this.merchantConfig.name; + } + + async stop(): Promise<void> { + const httpd = this.proc; + if (httpd) { + httpd.proc.kill("SIGTERM"); + await httpd.wait(); + this.proc = undefined; + } + } + + async dbinit() { + await runCommand( + this.globalState, + "merchant-dbinit", + "taler-merchant-dbinit", + ["-c", this.configFilename], + ); + } + + /** + * Start the merchant, + */ + async start(opts: { skipDbinit?: boolean } = {}): Promise<void> { + const skipSetup = opts.skipDbinit ?? false; + + if (!skipSetup) { + await this.dbinit(); + } + + this.proc = this.globalState.spawnService( + "taler-merchant-httpd", + [ + "taler-merchant-httpd", + "-LDEBUG", + "-c", + this.configFilename, + ...this.timetravelArgArr, + ], + `merchant-${this.merchantConfig.name}`, + ); + } + + static async create( + gc: GlobalTestState, + mc: MerchantConfig, + ): Promise<MerchantService> { + const testDir = mc.overrideTestDir ?? gc.testDir; + const config = new Configuration(); + config.setString("taler", "currency", mc.currency); + + const cfgFilename = testDir + `/merchant-${mc.name}.conf`; + setTalerPaths(config, testDir + "/talerhome"); + config.setString("merchant", "serve", "tcp"); + config.setString("merchant", "port", `${mc.httpPort}`); + config.setString( + "merchant", + "keyfile", + "${TALER_DATA_HOME}/merchant/merchant.priv", + ); + config.setString("merchantdb-postgres", "config", mc.database); + // Do not contact demo.taler.net exchange in tests + config.setString("merchant-exchange-kudos", "disabled", "yes"); + config.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<void> { + return await this.addInstanceWithWireAccount({ + id: "default", + name: "Default Instance", + paytoUris: [generateRandomPayto("merchant-default")], + auth: { + method: "external", + }, + }); + } + + /** + * Add an instance together with a wire account. + */ + async addInstanceWithWireAccount( + instanceConfig: PartialMerchantInstanceConfig, + ): Promise<void> { + if (!this.proc) { + throw Error("merchant must be running to add instance"); + } + logger.info(`adding instance '${instanceConfig.id}'`); + const url = `http://localhost:${this.merchantConfig.httpPort}/management/instances`; + const auth = instanceConfig.auth ?? { method: "external" }; + + const body: MerchantInstanceConfig = { + auth, + id: instanceConfig.id, + name: instanceConfig.name, + address: instanceConfig.address ?? {}, + jurisdiction: instanceConfig.jurisdiction ?? {}, + // FIXME: In some tests, we might want to make this configurable + use_stefan: true, + default_wire_transfer_delay: + instanceConfig.defaultWireTransferDelay ?? + Duration.toTalerProtocolDuration( + Duration.fromSpec({ + days: 1, + }), + ), + default_pay_delay: + instanceConfig.defaultPayDelay ?? + Duration.toTalerProtocolDuration(Duration.getForever()), + }; + const resp = await harnessHttpLib.fetch(url, { method: "POST", body }); + await expectSuccessResponseOrThrow(resp); + + const accountCreateUrl = `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceConfig.id}/private/accounts`; + for (const paytoUri of instanceConfig.paytoUris) { + const accountReq: 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<void> { + const url = `http://localhost:${this.merchantConfig.httpPort}/config`; + await pingProc(this.proc, url, `merchant (${this.merchantConfig.name})`); + } +} + +type TestStatus = "pass" | "fail" | "skip"; + +export interface TestRunResult { + /** + * Name of the test. + */ + name: string; + + /** + * How long did the test run? + */ + timeSec: number; + + status: TestStatus; + + reason?: string; +} + +export async function runTestWithState( + gc: GlobalTestState, + testMain: (t: GlobalTestState) => Promise<void>, + testName: string, + linger: boolean = false, +): Promise<TestRunResult> { + const startMs = new Date().getTime(); + + const p = openPromise(); + let status: TestStatus; + + const handleSignal = (s: string) => { + logger.warn( + `**** received fatal process event (${s}), terminating test ${testName}`, + ); + gc.shutdownSync(); + process.exit(1); + }; + + process.on("SIGINT", handleSignal); + process.on("SIGTERM", handleSignal); + + process.on("unhandledRejection", (reason: unknown, promise: any) => { + logger.warn( + `**** received unhandled rejection (${reason}), terminating test ${testName}`, + ); + logger.warn(`reason type: ${typeof reason}`); + gc.shutdownSync(); + process.exit(1); + }); + process.on("uncaughtException", (error, origin) => { + logger.warn( + `**** received uncaught exception (${error}), terminating test ${testName}`, + ); + console.warn("stack", error.stack); + gc.shutdownSync(); + process.exit(1); + }); + + try { + logger.info("running test in directory", gc.testDir); + await Promise.race([testMain(gc), p.promise]); + logger.info("completed test in directory", gc.testDir); + status = "pass"; + if (linger) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }); + await new Promise<void>((resolve, reject) => { + rl.question("Press enter to shut down test.", () => { + logger.error("Requested shutdown"); + resolve(); + }); + }); + rl.close(); + } + } catch (e) { + if (e instanceof CommandError) { + console.error("FATAL: test failed for", e.logName); + const errorLog = fs.readFileSync( + path.join(gc.testDir, `${e.logName}-stderr.log`), + ); + console.error(`${e.message}: "${e.command}"`); + console.error(errorLog.toString()); + console.error(e); + } else if (e instanceof TalerError) { + console.error( + "FATAL: test failed", + e.message, + `error detail: ${j2s(e.errorDetail)}`, + ); + console.error(e.stack); + } else { + console.error("FATAL: test failed with exception", e); + } + status = "fail"; + } finally { + await gc.shutdown(); + } + const afterMs = new Date().getTime(); + return { + name: testName, + timeSec: (afterMs - startMs) / 1000, + status, + }; +} + +function shellWrap(s: string) { + return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"; +} + +export interface WalletCliOpts { + cryptoWorkerType?: "sync" | "node-worker-thread"; +} + +function tryUnixConnect(socketPath: string): Promise<void> { + return new Promise((resolve, reject) => { + const client = net.createConnection(socketPath); + client.on("error", (e) => { + reject(e); + }); + client.on("connect", () => { + client.end(); + resolve(); + }); + }); +} + +export interface WalletServiceOptions { + useInMemoryDb?: boolean; + /** + * Use a particular DB path instead of the default one in the + * test environment. + */ + overrideDbPath?: string; + name: string; +} + +/** + * A wallet service that listens on a unix domain socket for commands. + */ +export class WalletService { + walletProc: ProcessWrapper | undefined; + + private internalDbPath: string; + + constructor( + private globalState: GlobalTestState, + private opts: WalletServiceOptions, + ) { + if (this.opts.overrideDbPath) { + this.internalDbPath = this.opts.overrideDbPath; + } else { + if (this.opts.useInMemoryDb) { + this.internalDbPath = ":memory:"; + } else { + this.internalDbPath = path.join( + this.globalState.testDir, + `walletdb-${this.opts.name}.sqlite3`, + ); + } + } + } + + get socketPath() { + const unixPath = path.join( + this.globalState.testDir, + `${this.opts.name}.sock`, + ); + return unixPath; + } + + get dbPath() { + return this.internalDbPath; + } + + async stop(): Promise<void> { + if (this.walletProc) { + this.walletProc.proc.kill("SIGTERM"); + await this.walletProc.wait(); + } + } + + async start(): Promise<void> { + const unixPath = this.socketPath; + this.walletProc = this.globalState.spawnService( + "taler-wallet-cli", + [ + "--wallet-db", + this.dbPath, + "-LTRACE", // FIXME: Make this configurable? + "--no-throttle", // FIXME: Optionally do throttling for some tests? + "advanced", + "serve", + "--unix-path", + unixPath, + "--no-init", + ], + `wallet-${this.opts.name}`, + ); + logger.info( + `hint: connect to wallet using taler-wallet-cli --wallet-connection=${unixPath}`, + ); + } + + async pingUntilAvailable(): Promise<void> { + let nextDelay = backoffStart(); + while (1) { + try { + await tryUnixConnect(this.socketPath); + } catch (e) { + logger.info(`wallet connection attempt failed: ${e}`); + logger.info(`waiting on wallet for ${nextDelay}ms`); + await delayMs(nextDelay); + nextDelay = backoffIncrement(nextDelay); + continue; + } + logger.info("connection to wallet-core succeeded"); + break; + } + } +} + +export interface WalletClientArgs { + name?: string; + unixPath: string; + onNotification?(n: WalletNotification): void; +} + +export type CancelFn = () => void; +export type NotificationHandler = (n: WalletNotification) => void; + +/** + * Convenience wrapper around a (remote) wallet handle. + */ +export class WalletClient { + remoteWallet: RemoteWallet | undefined = undefined; + private waiter: WalletNotificationWaiter = makeNotificationWaiter(); + notificationHandlers: NotificationHandler[] = []; + + addNotificationListener(f: NotificationHandler): CancelFn { + this.notificationHandlers.push(f); + return () => { + const idx = this.notificationHandlers.indexOf(f); + if (idx >= 0) { + this.notificationHandlers.splice(idx, 1); + } + }; + } + + async call<Op extends keyof WalletOperations>( + operation: Op, + payload: WalletCoreRequestType<Op>, + ): Promise<WalletCoreResponseType<Op>> { + if (!this.remoteWallet) { + throw Error("wallet not connected"); + } + const client = getClientFromRemoteWallet(this.remoteWallet); + return client.call(operation, payload); + } + + constructor(private args: WalletClientArgs) {} + + async connect(): Promise<void> { + const waiter = this.waiter; + const walletClient = this; + const w = await createRemoteWallet({ + name: this.args.name, + socketFilename: this.args.unixPath, + notificationHandler(n) { + if (walletClient.args.onNotification) { + walletClient.args.onNotification(n); + } + waiter.notify(n); + for (const h of walletClient.notificationHandlers) { + h(n); + } + }, + }); + this.remoteWallet = w; + + this.waiter.waitForNotificationCond; + } + + get client() { + if (!this.remoteWallet) { + throw Error("wallet not connected"); + } + return getClientFromRemoteWallet(this.remoteWallet); + } + + waitForNotificationCond<T>( + cond: (n: WalletNotification) => T | undefined | false, + ): Promise<T> { + return this.waiter.waitForNotificationCond(cond); + } +} + +export class WalletCli { + private currentTimetravel: Duration | undefined; + private _client: WalletCoreApiClient; + + setTimetravel(d: Duration | undefined) { + this.currentTimetravel = d; + } + + private get timetravelArg(): string | undefined { + if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") { + // Convert to microseconds + return `--timetravel=${this.currentTimetravel.d_ms * 1000}`; + } + return undefined; + } + + constructor( + private globalTestState: GlobalTestState, + private name: string = "default", + cliOpts: WalletCliOpts = {}, + ) { + const self = this; + this._client = { + async call(op: any, payload: any): Promise<any> { + logger.info( + `calling wallet with timetravel arg ${j2s(self.timetravelArg)}`, + ); + const cryptoWorkerArg = cliOpts.cryptoWorkerType + ? `--crypto-worker=${cliOpts.cryptoWorkerType}` + : ""; + const logName = `wallet-${self.name}`; + const command = `taler-wallet-cli ${ + self.timetravelArg ?? "" + } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${ + self.dbfile + }' api '${op}' ${shellWrap(JSON.stringify(payload))}`; + const resp = await sh(self.globalTestState, logName, command); + logger.info("--- wallet core response ---"); + logger.info(resp); + logger.info("--- end of response ---"); + let ar: CoreApiResponse; + try { + ar = JSON.parse(resp); + } catch (e) { + throw new CommandError( + "wallet CLI did not return a proper JSON response", + logName, + command, + [], + {}, + null, + ); + } + if (ar.type === "error") { + throw TalerError.fromUncheckedDetail(ar.error); + } + return ar.result; + }, + }; + } + + get dbfile(): string { + return this.globalTestState.testDir + `/walletdb-${this.name}.json`; + } + + deleteDatabase() { + fs.unlinkSync(this.dbfile); + } + + private get timetravelArgArr(): string[] { + const tta = this.timetravelArg; + if (tta) { + return [tta]; + } + return []; + } + + get client(): WalletCoreApiClient { + return this._client; + } + + async runUntilDone(args: {} = {}): Promise<void> { + await runCommand( + this.globalTestState, + `wallet-${this.name}`, + "taler-wallet-cli", + [ + "--no-throttle", + ...this.timetravelArgArr, + "-LTRACE", + "--skip-defaults", + "--wallet-db", + this.dbfile, + "run-until-done", + ], + ); + } + + async runPending(): Promise<void> { + await runCommand( + this.globalTestState, + `wallet-${this.name}`, + "taler-wallet-cli", + [ + "--no-throttle", + "--skip-defaults", + "-LTRACE", + ...this.timetravelArgArr, + "--wallet-db", + this.dbfile, + "advanced", + "run-pending", + ], + ); + } +} + +export function generateRandomTestIban(salt: string | null = null): string { + function getBban(salt: string | null): string { + if (!salt) return Math.random().toString().substring(2, 6); + let hashed = hash(stringToBytes(salt)); + let ret = ""; + for (let i = 0; i < hashed.length; i++) { + ret += hashed[i].toString(); + } + return ret.substring(0, 4); + } + + let cc_no_check = "131400"; // == DE00 + let bban = getBban(salt); + let check_digits = ( + 98 - + (Number.parseInt(`${bban}${cc_no_check}`) % 97) + ).toString(); + if (check_digits.length == 1) { + check_digits = `0${check_digits}`; + } + return `DE${check_digits}${bban}`; +} + +export function getWireMethodForTest(): string { + 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<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} |