/* 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 * as util from "util"; import * as fs from "fs"; import * as path from "path"; import * as http from "http"; import * as readline from "readline"; import { deepStrictEqual } from "assert"; import { ChildProcess, spawn } from "child_process"; import { URL } from "url"; import axios, { AxiosError } from "axios"; import { codecForMerchantOrderPrivateStatusResponse, codecForPostOrderResponse, PostOrderRequest, PostOrderResponse, MerchantOrderPrivateStatusResponse, TippingReserveStatus, TipCreateConfirmation, TipCreateRequest, MerchantInstancesResponse, } from "./merchantApiTypes"; import { BankServiceHandle, HarnessExchangeBankAccount, NodeHttpLib, openPromise, TalerError, WalletCoreApiClient, } from "@gnu-taler/taler-wallet-core"; import { AmountJson, Amounts, Configuration, AmountString, Codec, buildCodecForObject, codecForString, Duration, parsePaytoUri, CoreApiResponse, createEddsaKeyPair, eddsaGetPublic, EddsaKeyPair, encodeCrock, getRandomBytes, hash, stringToBytes, j2s, } from "@gnu-taler/taler-util"; import { CoinConfig } from "./denomStructures.js"; import { LibeufinNexusApi, LibeufinSandboxApi } from "./libeufin-apis.js"; const exec = util.promisify(require("child_process").exec); 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; } /** * Run a shell command, return stdout. */ export async function sh( t: GlobalTestState, logName: string, command: string, env: { [index: string]: string | undefined } = process.env, ): Promise { console.log("running command", command); return new Promise((resolve, reject) => { const stdoutChunks: Buffer[] = []; const proc = spawn(command, { stdio: ["inherit", "pipe", "pipe"], shell: true, env: env, }); proc.stdout.on("data", (x) => { if (x instanceof Buffer) { stdoutChunks.push(x); } else { throw Error("unexpected data chunk type"); } }); const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`); const stderrLog = fs.createWriteStream(stderrLogFileName, { flags: "a", }); proc.stderr.pipe(stderrLog); proc.on("exit", (code, signal) => { console.log(`child process exited (${code} / ${signal})`); if (code != 0) { reject(Error(`Unexpected exit code ${code} for '${command}'`)); return; } const b = Buffer.concat(stdoutChunks).toString("utf-8"); resolve(b); }); proc.on("error", () => { reject(Error("Child process had error")); }); }); } function shellescape(args: string[]) { const ret = args.map((s) => { if (/[^A-Za-z0-9_\/:=-]/.test(s)) { s = "'" + s.replace(/'/g, "'\\''") + "'"; s = s.replace(/^(?:'')+/g, "").replace(/\\'''/g, "\\'"); } return s; }); return ret.join(" "); } /** * Run a shell command, return stdout. * * Log stderr to a log file. */ export async function runCommand( t: GlobalTestState, logName: string, command: string, args: string[], env: { [index: string]: string | undefined } = process.env, ): Promise { console.log("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) => { console.log(`child process exited (${code} / ${signal})`); if (code != 0) { reject(Error(`Unexpected exit code ${code} for '${command}'`)); return; } const b = Buffer.concat(stdoutChunks).toString("utf-8"); resolve(b); }); proc.on("error", () => { reject(Error("Child process had error")); }); }); } export class ProcessWrapper { private waitPromise: Promise; 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`, ); } assertAxiosError(e: any): asserts e is AxiosError { if (!e.isAxiosError) { throw Error("expected axios error"); } } 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 { console.log( `spawning process (${logName}): ${shellescape([command, ...args])}`, ); const proc = spawn(command, args, { stdio: ["inherit", "pipe", "pipe"], env: env, }); console.log(`spawned process (${logName}) with pid ${proc.pid}`); proc.on("error", (err) => { console.log(`could not start process (${command})`, err); }); proc.on("exit", (code, signal) => { console.log(`process ${logName} exited ${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()) { console.log("refusing to shut down, lingering was requested"); return; } this.inShutdown = true; console.log("shutting down"); for (const s of this.servers) { s.close(); s.removeAllListeners(); } for (const p of this.procs) { if (p.proc.exitCode == null) { console.log("killing process", p.proc.pid); p.proc.kill("SIGTERM"); await p.wait(); } } } } export 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 async function setupDb(gc: GlobalTestState): Promise { const dbname = "taler-integrationtest"; await exec(`dropdb "${dbname}" || true`); await exec(`createdb "${dbname}"`); return { connStr: `postgres:///${dbname}`, dbname, }; } export interface BankConfig { currency: string; httpPort: number; database: string; allowRegistrations: boolean; maxDebt?: string; } export interface FakeBankConfig { currency: string; httpPort: number; } function setTalerPaths(config: Configuration, home: 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 runDir = fs.mkdtempSync("/tmp/taler-test-"); 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.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(); } } /** * Send an HTTP request until it suceeds 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`); } while (true) { try { console.log(`pinging ${serviceName} at ${url}`); const resp = await axios.get(url); console.log(`service ${serviceName} available`); return; } catch (e: any) { console.log(`service ${serviceName} not ready:`, e.toString()); //console.log(e); await delayMs(1000); } 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, ) {} } /** * Work in progress. The key point is that both Sandbox and Nexus * will be configured and started by this class. */ class EufinBankService extends BankServiceBase implements BankServiceHandle { sandboxProc: ProcessWrapper | undefined; nexusProc: ProcessWrapper | undefined; http = new NodeHttpLib(); static async create( gc: GlobalTestState, bc: BankConfig, ): Promise { return new EufinBankService(gc, bc, "foo"); } get port() { return this.bankConfig.httpPort; } get nexusPort() { return this.bankConfig.httpPort + 1000; } get nexusDbConn(): string { return `jdbc:sqlite:${this.globalTestState.testDir}/libeufin-nexus.sqlite3`; } get sandboxDbConn(): string { return `jdbc:sqlite:${this.globalTestState.testDir}/libeufin-sandbox.sqlite3`; } get nexusBaseUrl(): string { return `http://localhost:${this.nexusPort}`; } get baseUrlDemobank(): string { let url = new URL("demobanks/default/", this.baseUrlNetloc); return url.href; } get baseUrlAccessApi(): string { let url = new URL("access-api/", this.baseUrlDemobank); return url.href; } get baseUrlNetloc(): string { return `http://localhost:${this.bankConfig.httpPort}/`; } get baseUrl(): string { return this.baseUrlAccessApi; } async setSuggestedExchange( e: ExchangeServiceInterface, exchangePayto: string, ) { await sh( this.globalTestState, "libeufin-sandbox-set-default-exchange", `libeufin-sandbox default-exchange ${e.baseUrl} ${exchangePayto}`, { ...process.env, LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxDbConn, }, ); } // Create one at both sides: Sandbox and Nexus. async createExchangeAccount( accountName: string, password: string, ): Promise { console.log("Create Exchange account(s)!"); /** * Many test cases try to create a Exchange account before * starting the bank; that's because the Pybank did it entirely * via the configuration file. */ await this.start(); await this.pingUntilAvailable(); await LibeufinSandboxApi.createDemobankAccount(accountName, password, { baseUrl: this.baseUrlAccessApi, }); let bankAccountLabel = accountName; await LibeufinSandboxApi.createDemobankEbicsSubscriber( { hostID: "talertestEbicsHost", userID: "exchangeEbicsUser", partnerID: "exchangeEbicsPartner", }, bankAccountLabel, { baseUrl: this.baseUrlDemobank }, ); await LibeufinNexusApi.createUser( { baseUrl: this.nexusBaseUrl }, { username: accountName, password: password, }, ); await LibeufinNexusApi.createEbicsBankConnection( { baseUrl: this.nexusBaseUrl }, { name: "ebics-connection", // connection name. ebicsURL: new URL("ebicsweb", this.baseUrlNetloc).href, hostID: "talertestEbicsHost", userID: "exchangeEbicsUser", partnerID: "exchangeEbicsPartner", }, ); await LibeufinNexusApi.connectBankConnection( { baseUrl: this.nexusBaseUrl }, "ebics-connection", ); await LibeufinNexusApi.fetchAccounts( { baseUrl: this.nexusBaseUrl }, "ebics-connection", ); await LibeufinNexusApi.importConnectionAccount( { baseUrl: this.nexusBaseUrl }, "ebics-connection", // connection name accountName, // offered account label `${accountName}-nexus-label`, // bank account label at Nexus ); await LibeufinNexusApi.createTwgFacade( { baseUrl: this.nexusBaseUrl }, { name: "exchange-facade", connectionName: "ebics-connection", accountName: `${accountName}-nexus-label`, currency: "EUR", reserveTransferLevel: "report", }, ); await LibeufinNexusApi.postPermission( { baseUrl: this.nexusBaseUrl }, { action: "grant", permission: { subjectId: accountName, subjectType: "user", resourceType: "facade", resourceId: "exchange-facade", // facade name permissionName: "facade.talerWireGateway.transfer", }, }, ); await LibeufinNexusApi.postPermission( { baseUrl: this.nexusBaseUrl }, { action: "grant", permission: { subjectId: accountName, subjectType: "user", resourceType: "facade", resourceId: "exchange-facade", // facade name permissionName: "facade.talerWireGateway.history", }, }, ); // Set fetch task. await LibeufinNexusApi.postTask( { baseUrl: this.nexusBaseUrl }, `${accountName}-nexus-label`, { name: "wirewatch-task", cronspec: "* * *", type: "fetch", params: { level: "all", rangeType: "all", }, }, ); await LibeufinNexusApi.postTask( { baseUrl: this.nexusBaseUrl }, `${accountName}-nexus-label`, { name: "aggregator-task", cronspec: "* * *", type: "submit", params: {}, }, ); let facadesResp = await LibeufinNexusApi.getAllFacades({ baseUrl: this.nexusBaseUrl, }); let accountInfoResp = await LibeufinSandboxApi.demobankAccountInfo( "admin", "secret", { baseUrl: this.baseUrlAccessApi }, accountName, // bank account label. ); return { accountName: accountName, accountPassword: password, accountPaytoUri: accountInfoResp.data.paytoUri, wireGatewayApiBaseUrl: facadesResp.data.facades[0].baseUrl, }; } async start(): Promise { /** * Because many test cases try to create a Exchange bank * account _before_ starting the bank (Pybank did it only via * the config), it is possible that at this point Sandbox and * Nexus are already running. Hence, this method only launches * them if they weren't launched earlier. */ // Only go ahead if BOTH aren't running. if (this.sandboxProc || this.nexusProc) { console.log("Nexus or Sandbox already running, not taking any action."); return; } await sh( this.globalTestState, "libeufin-sandbox-config-demobank", `libeufin-sandbox config --currency=${this.bankConfig.currency} default`, { ...process.env, LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxDbConn, LIBEUFIN_SANDBOX_ADMIN_PASSWORD: "secret", }, ); this.sandboxProc = this.globalTestState.spawnService( "libeufin-sandbox", ["serve", "--port", `${this.port}`], "libeufin-sandbox", { ...process.env, LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxDbConn, LIBEUFIN_SANDBOX_ADMIN_PASSWORD: "secret", }, ); await runCommand( this.globalTestState, "libeufin-nexus-superuser", "libeufin-nexus", ["superuser", "admin", "--password", "test"], { ...process.env, LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusDbConn, }, ); this.nexusProc = this.globalTestState.spawnService( "libeufin-nexus", ["serve", "--port", `${this.nexusPort}`], "libeufin-nexus", { ...process.env, LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusDbConn, }, ); // need to wait here, because at this point // a Ebics host needs to be created (RESTfully) await this.pingUntilAvailable(); LibeufinSandboxApi.createEbicsHost( { baseUrl: this.baseUrlNetloc }, "talertestEbicsHost", ); } async pingUntilAvailable(): Promise { await pingProc( this.sandboxProc, `http://localhost:${this.bankConfig.httpPort}`, "libeufin-sandbox", ); await pingProc( this.nexusProc, `${this.nexusBaseUrl}/config`, "libeufin-nexus", ); } } class PybankService extends BankServiceBase implements BankServiceHandle { proc: ProcessWrapper | undefined; http = new NodeHttpLib(); static async create( gc: GlobalTestState, bc: BankConfig, ): Promise { const config = new Configuration(); setTalerPaths(config, gc.testDir + "/talerhome"); config.setString("taler", "currency", bc.currency); config.setString("bank", "database", bc.database); config.setString("bank", "http_port", `${bc.httpPort}`); config.setString("bank", "serve", "http"); config.setString("bank", "max_debt_bank", `${bc.currency}:999999`); config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`); config.setString( "bank", "allow_registrations", bc.allowRegistrations ? "yes" : "no", ); const cfgFilename = gc.testDir + "/bank.conf"; config.write(cfgFilename); await sh( gc, "taler-bank-manage_django", `taler-bank-manage -c '${cfgFilename}' django migrate`, ); await sh( gc, "taler-bank-manage_django", `taler-bank-manage -c '${cfgFilename}' django provide_accounts`, ); return new PybankService(gc, bc, cfgFilename); } setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) { const config = Configuration.load(this.configFile); config.setString("bank", "suggested_exchange", e.baseUrl); config.setString("bank", "suggested_exchange_payto", exchangePayto); config.write(this.configFile); } get baseUrl(): string { return `http://localhost:${this.bankConfig.httpPort}/`; } async createExchangeAccount( accountName: string, password: string, ): Promise { await sh( this.globalTestState, "taler-bank-manage_django", `taler-bank-manage -c '${this.configFile}' django add_bank_account ${accountName}`, ); await sh( this.globalTestState, "taler-bank-manage_django", `taler-bank-manage -c '${this.configFile}' django changepassword_unsafe ${accountName} ${password}`, ); await sh( this.globalTestState, "taler-bank-manage_django", `taler-bank-manage -c '${this.configFile}' django top_up ${accountName} ${this.bankConfig.currency}:100000`, ); return { accountName: accountName, accountPassword: password, accountPaytoUri: getPayto(accountName), wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/taler-wire-gateway/${accountName}/`, }; } get port() { return this.bankConfig.httpPort; } async start(): Promise { this.proc = this.globalTestState.spawnService( "taler-bank-manage", ["-c", this.configFile, "serve"], "bank", ); } async pingUntilAvailable(): Promise { const url = `http://localhost:${this.bankConfig.httpPort}/config`; await pingProc(this.proc, url, "bank"); } } /** * Return a euFin or a pyBank implementation of * the exported BankService class. This allows * to "dynamically export" such class depending * on a particular env variable. */ function getBankServiceImpl(): { prototype: typeof PybankService.prototype; create: typeof PybankService.create; } { if (process.env.WALLET_HARNESS_WITH_EUFIN) return { prototype: EufinBankService.prototype, create: EufinBankService.create, }; return { prototype: PybankService.prototype, create: PybankService.create, }; } export type BankService = PybankService; export const BankService = getBankServiceImpl(); export class FakeBankService { proc: ProcessWrapper | undefined; static fromExistingConfig(gc: GlobalTestState): FakeBankService { const cfgFilename = gc.testDir + "/bank.conf"; console.log("reading fakebank config from", cfgFilename); const config = Configuration.load(cfgFilename); const bc: FakeBankConfig = { currency: config.getString("taler", "currency").required(), httpPort: config.getNumber("bank", "http_port").required(), }; return new FakeBankService(gc, bc, cfgFilename); } static async create( gc: GlobalTestState, bc: FakeBankConfig, ): Promise { const config = new Configuration(); setTalerPaths(config, gc.testDir + "/talerhome"); config.setString("taler", "currency", bc.currency); config.setString("bank", "http_port", `${bc.httpPort}`); config.setString("bank", "ram_limit", `${1024}`); const cfgFilename = gc.testDir + "/bank.conf"; config.write(cfgFilename); return new FakeBankService(gc, bc, cfgFilename); } get baseUrl(): string { return `http://localhost:${this.bankConfig.httpPort}/`; } get port() { return this.bankConfig.httpPort; } private constructor( private globalTestState: GlobalTestState, private bankConfig: FakeBankConfig, private configFile: string, ) {} async start(): Promise { this.proc = this.globalTestState.spawnService( "taler-fakebank-run", ["-c", this.configFile], "fakebank", ); } async pingUntilAvailable(): Promise { // Fakebank doesn't have "/config", so we ping just "/". const url = `http://localhost:${this.bankConfig.httpPort}/`; await pingProc(this.proc, url, "bank"); } } export interface ExchangeConfig { name: string; currency: string; roundUnit?: string; httpPort: number; database: string; } export interface ExchangeServiceInterface { readonly baseUrl: string; readonly port: number; readonly name: string; readonly masterPub: string; } export class ExchangeService implements ExchangeServiceInterface { static fromExistingConfig(gc: GlobalTestState, exchangeName: string) { const cfgFilename = gc.testDir + `/exchange-${exchangeName}.conf`; const config = Configuration.load(cfgFilename); const ec: ExchangeConfig = { currency: config.getString("taler", "currency").required(), database: config.getString("exchangedb-postgres", "config").required(), httpPort: config.getNumber("exchange", "port").required(), name: exchangeName, roundUnit: config.getString("taler", "currency_round_unit").required(), }; const privFile = config.getPath("exchange", "master_priv_file").required(); const eddsaPriv = fs.readFileSync(privFile); const keyPair: EddsaKeyPair = { eddsaPriv, eddsaPub: eddsaGetPublic(eddsaPriv), }; return new ExchangeService(gc, ec, cfgFilename, keyPair); } private currentTimetravel: Duration | undefined; setTimetravel(t: Duration | undefined): void { if (this.isRunning()) { throw Error("can't set time travel while the exchange is running"); } this.currentTimetravel = t; } private get timetravelArg(): string | undefined { if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") { // Convert to microseconds return `--timetravel=+${this.currentTimetravel.d_ms * 1000}`; } return undefined; } /** * Return an empty array if no time travel is set, * and an array with the time travel command line argument * otherwise. */ private get timetravelArgArr(): string[] { const tta = this.timetravelArg; if (tta) { return [tta]; } return []; } async runWirewatchOnce() { if (process.env.WALLET_HARNESS_WITH_EUFIN) { // Not even 2 secods 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 runAggregatorOnce() { try { await runCommand( this.globalState, `exchange-${this.name}-aggregator-once`, "taler-exchange-aggregator", [...this.timetravelArgArr, "-c", this.configFilename, "-t", "-y"], ); } catch (e) { console.log( "running aggregator with KYC off didn't work, might be old version, running again", ); await runCommand( this.globalState, `exchange-${this.name}-aggregator-once`, "taler-exchange-aggregator", [...this.timetravelArgArr, "-c", this.configFilename, "-t"], ); } } async runTransferOnce() { await runCommand( this.globalState, `exchange-${this.name}-transfer-once`, "taler-exchange-transfer", [...this.timetravelArgArr, "-c", this.configFilename, "-t"], ); } changeConfig(f: (config: Configuration) => void) { const config = Configuration.load(this.configFilename); f(config); config.write(this.configFilename); } static create(gc: GlobalTestState, e: ExchangeConfig) { const config = new Configuration(); config.setString("taler", "currency", e.currency); config.setString( "taler", "currency_round_unit", e.roundUnit ?? `${e.currency}:0.01`, ); setTalerPaths(config, gc.testDir + "/talerhome"); 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", "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"); const exchangeMasterKey = createEddsaKeyPair(); config.setString( "exchange", "master_public_key", encodeCrock(exchangeMasterKey.eddsaPub), ); const masterPrivFile = config .getPath("exchange-offline", "master_priv_file") .required(); fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true }); fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv)); const cfgFilename = gc.testDir + `/exchange-${e.name}.conf`; config.write(cfgFilename); return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey); } addOfferedCoins(offeredCoins: ((curr: string) => CoinConfig)[]) { const config = Configuration.load(this.configFilename); offeredCoins.forEach((cc) => setCoin(config, cc(this.exchangeConfig.currency)), ); config.write(this.configFilename); } addCoinConfigList(ccs: CoinConfig[]) { const config = Configuration.load(this.configFilename); ccs.forEach((cc) => setCoin(config, cc)); config.write(this.configFilename); } get masterPub() { return encodeCrock(this.keyPair.eddsaPub); } get port() { return this.exchangeConfig.httpPort; } async addBankAccount( localName: string, exchangeBankAccount: HarnessExchangeBankAccount, ): Promise { 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); } exchangeHttpProc: ProcessWrapper | undefined; exchangeWirewatchProc: 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; } async stop(): Promise { const wirewatch = this.exchangeWirewatchProc; if (wirewatch) { wirewatch.proc.kill("SIGTERM"); await wirewatch.wait(); this.exchangeWirewatchProc = undefined; } const httpd = this.exchangeHttpProc; if (httpd) { httpd.proc.kill("SIGTERM"); await httpd.wait(); this.exchangeHttpProc = undefined; } const cryptoRsa = this.helperCryptoRsaProc; if (cryptoRsa) { cryptoRsa.proc.kill("SIGTERM"); await cryptoRsa.wait(); this.helperCryptoRsaProc = undefined; } const cryptoEddsa = this.helperCryptoEddsaProc; if (cryptoEddsa) { cryptoEddsa.proc.kill("SIGTERM"); await cryptoEddsa.wait(); this.helperCryptoRsaProc = undefined; } 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 accounts: string[] = []; const accountTargetTypes: Set = new Set(); const config = Configuration.load(this.configFilename); for (const sectionName of config.getSectionNames()) { if (sectionName.startsWith("EXCHANGE-ACCOUNT-")) { const paytoUri = config.getString(sectionName, "payto_uri").required(); const p = parsePaytoUri(paytoUri); if (!p) { throw Error(`invalid payto uri in exchange config: ${paytoUri}`); } accountTargetTypes.add(p?.targetType); accounts.push(paytoUri); } } console.log("configuring bank accounts", accounts); for (const acc of accounts) { await runCommand( this.globalState, "exchange-offline", "taler-exchange-offline", ["-c", this.configFilename, "enable-account", acc, "upload"], ); } const year = new Date().getFullYear(); for (const accTargetType of accountTargetTypes.values()) { for (let i = year; i < year + 5; i++) { await runCommand( this.globalState, "exchange-offline", "taler-exchange-offline", [ "-c", this.configFilename, "wire-fee", `${i}`, accTargetType, `${this.exchangeConfig.currency}:0.01`, `${this.exchangeConfig.currency}:0.01`, `${this.exchangeConfig.currency}:0.01`, "upload", ], ); } } } async revokeDenomination(denomPubHash: string) { if (!this.isRunning()) { throw Error("exchange must be running when revoking denominations"); } await runCommand( this.globalState, "exchange-offline", "taler-exchange-offline", [ "-c", this.configFilename, "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}"`, ); } async start(): Promise { if (this.isRunning()) { throw Error("exchange is already running"); } await sh( this.globalState, "exchange-dbinit", `taler-exchange-dbinit -c "${this.configFilename}"`, ); this.helperCryptoEddsaProc = this.globalState.spawnService( "taler-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.exchangeWirewatchProc = this.globalState.spawnService( "taler-exchange-wirewatch", ["-c", this.configFilename, ...this.timetravelArgArr], `exchange-wirewatch-${this.name}`, ); this.exchangeHttpProc = this.globalState.spawnService( "taler-exchange-httpd", ["-LINFO", "-c", this.configFilename, ...this.timetravelArgArr], `exchange-httpd-${this.name}`, ); await this.pingUntilAvailable(); await this.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; } export interface PrivateOrderStatusQuery { instance?: string; orderId: string; sessionId?: string; } export interface MerchantServiceInterface { makeInstanceBaseUrl(instanceName?: string): string; readonly port: number; readonly name: string; } export class MerchantApiClient { constructor( private baseUrl: string, public readonly auth: MerchantAuthConfiguration, ) {} async changeAuth(auth: MerchantAuthConfiguration): Promise { const url = new URL("private/auth", this.baseUrl); await axios.post(url.href, auth, { headers: this.makeAuthHeader(), }); } async deleteInstance(instanceId: string) { const url = new URL(`management/instances/${instanceId}`, this.baseUrl); await axios.delete(url.href, { headers: this.makeAuthHeader(), }); } async createInstance(req: MerchantInstanceConfig): Promise { const url = new URL("management/instances", this.baseUrl); await axios.post(url.href, req, { headers: this.makeAuthHeader(), }); } async getInstances(): Promise { const url = new URL("management/instances", this.baseUrl); const resp = await axios.get(url.href, { headers: this.makeAuthHeader(), }); return resp.data; } async getInstanceFullDetails(instanceId: string): Promise { const url = new URL(`management/instances/${instanceId}`, this.baseUrl); try { const resp = await axios.get(url.href, { headers: this.makeAuthHeader(), }); return resp.data; } catch (e) { throw e; } } makeAuthHeader(): Record { switch (this.auth.method) { case "external": return {}; case "token": return { Authorization: `Bearer ${this.auth.token}`, }; } } } /** * FIXME: This should be deprecated in favor of MerchantApiClient */ export namespace MerchantPrivateApi { export async function createOrder( merchantService: MerchantServiceInterface, instanceName: string, req: PostOrderRequest, withAuthorization: WithAuthorization = {}, ): Promise { const baseUrl = merchantService.makeInstanceBaseUrl(instanceName); let url = new URL("private/orders", baseUrl); const resp = await axios.post(url.href, req, { headers: withAuthorization as Record, }); return codecForPostOrderResponse().decode(resp.data); } export async function queryPrivateOrderStatus( merchantService: MerchantServiceInterface, query: PrivateOrderStatusQuery, withAuthorization: WithAuthorization = {}, ): Promise { const reqUrl = new URL( `private/orders/${query.orderId}`, merchantService.makeInstanceBaseUrl(query.instance), ); if (query.sessionId) { reqUrl.searchParams.set("session_id", query.sessionId); } const resp = await axios.get(reqUrl.href, { headers: withAuthorization as Record, }); return codecForMerchantOrderPrivateStatusResponse().decode(resp.data); } export async function giveRefund( merchantService: MerchantServiceInterface, r: { instance: string; orderId: string; amount: string; justification: string; }, ): Promise<{ talerRefundUri: string }> { const reqUrl = new URL( `private/orders/${r.orderId}/refund`, merchantService.makeInstanceBaseUrl(r.instance), ); const resp = await axios.post(reqUrl.href, { refund: r.amount, reason: r.justification, }); return { talerRefundUri: resp.data.taler_refund_uri, }; } export async function createTippingReserve( merchantService: MerchantServiceInterface, instance: string, req: CreateMerchantTippingReserveRequest, ): Promise { const reqUrl = new URL( `private/reserves`, merchantService.makeInstanceBaseUrl(instance), ); const resp = await axios.post(reqUrl.href, req); // FIXME: validate return resp.data; } export async function queryTippingReserves( merchantService: MerchantServiceInterface, instance: string, ): Promise { const reqUrl = new URL( `private/reserves`, merchantService.makeInstanceBaseUrl(instance), ); const resp = await axios.get(reqUrl.href); // FIXME: validate return resp.data; } export async function giveTip( merchantService: MerchantServiceInterface, instance: string, req: TipCreateRequest, ): Promise { const reqUrl = new URL( `private/tips`, merchantService.makeInstanceBaseUrl(instance), ); const resp = await axios.post(reqUrl.href, req); // FIXME: validate return resp.data; } } export interface CreateMerchantTippingReserveRequest { // Amount that the merchant promises to put into the reserve initial_balance: AmountString; // Exchange the merchant intends to use for tipping exchange_url: string; // Desired wire method, for example "iban" or "x-taler-bank" wire_method: string; } export interface CreateMerchantTippingReserveConfirmation { // Public key identifying the reserve reserve_pub: string; // Wire account of the exchange where to transfer the funds payto_uri: string; } export class MerchantService implements MerchantServiceInterface { static fromExistingConfig(gc: GlobalTestState, name: string) { const cfgFilename = gc.testDir + `/merchant-${name}.conf`; const config = Configuration.load(cfgFilename); const mc: MerchantConfig = { currency: config.getString("taler", "currency").required(), database: config.getString("merchantdb-postgres", "config").required(), httpPort: config.getNumber("merchant", "port").required(), name, }; return new MerchantService(gc, mc, cfgFilename); } proc: ProcessWrapper | undefined; constructor( private globalState: GlobalTestState, private merchantConfig: MerchantConfig, private configFilename: string, ) {} private currentTimetravel: Duration | undefined; private isRunning(): boolean { return !!this.proc; } setTimetravel(t: Duration | undefined): void { if (this.isRunning()) { throw Error("can't set time travel while the exchange is running"); } this.currentTimetravel = t; } private get timetravelArg(): string | undefined { if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") { // Convert to microseconds return `--timetravel=+${this.currentTimetravel.d_ms * 1000}`; } return undefined; } /** * Return an empty array if no time travel is set, * and an array with the time travel command line argument * otherwise. */ private get timetravelArgArr(): string[] { const tta = this.timetravelArg; if (tta) { return [tta]; } return []; } get port(): number { return this.merchantConfig.httpPort; } get name(): string { return this.merchantConfig.name; } async stop(): Promise { const httpd = this.proc; if (httpd) { httpd.proc.kill("SIGTERM"); await httpd.wait(); this.proc = undefined; } } async start(): Promise { await exec(`taler-merchant-dbinit -c "${this.configFilename}"`); this.proc = this.globalState.spawnService( "taler-merchant-httpd", ["-LDEBUG", "-c", this.configFilename, ...this.timetravelArgArr], `merchant-${this.merchantConfig.name}`, ); } static async create( gc: GlobalTestState, mc: MerchantConfig, ): Promise { const config = new Configuration(); config.setString("taler", "currency", mc.currency); const cfgFilename = gc.testDir + `/merchant-${mc.name}.conf`; setTalerPaths(config, gc.testDir + "/talerhome"); config.setString("merchant", "serve", "tcp"); config.setString("merchant", "port", `${mc.httpPort}`); config.setString( "merchant", "keyfile", "${TALER_DATA_HOME}/merchant/merchant.priv", ); config.setString("merchantdb-postgres", "config", mc.database); config.write(cfgFilename); return new MerchantService(gc, mc, cfgFilename); } addExchange(e: ExchangeServiceInterface): void { const config = Configuration.load(this.configFilename); config.setString( `merchant-exchange-${e.name}`, "exchange_base_url", e.baseUrl, ); config.setString( `merchant-exchange-${e.name}`, "currency", this.merchantConfig.currency, ); config.setString(`merchant-exchange-${e.name}`, "master_key", e.masterPub); config.write(this.configFilename); } async addDefaultInstance(): Promise { return await this.addInstance({ id: "default", name: "Default Instance", paytoUris: [getPayto("merchant-default")], auth: { method: "external", }, }); } async addInstance( instanceConfig: PartialMerchantInstanceConfig, ): Promise { if (!this.proc) { throw Error("merchant must be running to add instance"); } console.log("adding instance"); const url = `http://localhost:${this.merchantConfig.httpPort}/management/instances`; const auth = instanceConfig.auth ?? { method: "external" }; await axios.post(url, { auth, payto_uris: instanceConfig.paytoUris, id: instanceConfig.id, name: instanceConfig.name, address: instanceConfig.address ?? {}, jurisdiction: instanceConfig.jurisdiction ?? {}, default_max_wire_fee: instanceConfig.defaultMaxWireFee ?? `${this.merchantConfig.currency}:1.0`, default_wire_fee_amortization: instanceConfig.defaultWireFeeAmortization ?? 3, default_max_deposit_fee: instanceConfig.defaultMaxDepositFee ?? `${this.merchantConfig.currency}:1.0`, default_wire_transfer_delay: instanceConfig.defaultWireTransferDelay ?? Duration.fromSpec({ days: 1, }), default_pay_delay: instanceConfig.defaultPayDelay ?? { d_ms: "forever" }, }); } makeInstanceBaseUrl(instanceName?: string): string { if (instanceName === undefined || instanceName === "default") { return `http://localhost:${this.merchantConfig.httpPort}/`; } else { return `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/`; } } async pingUntilAvailable(): Promise { const url = `http://localhost:${this.merchantConfig.httpPort}/config`; await pingProc(this.proc, url, `merchant (${this.merchantConfig.name})`); } } export interface MerchantAuthConfiguration { method: "external" | "token"; token?: string; } export interface PartialMerchantInstanceConfig { auth?: MerchantAuthConfiguration; id: string; name: string; paytoUris: string[]; address?: unknown; jurisdiction?: unknown; defaultMaxWireFee?: string; defaultMaxDepositFee?: string; defaultWireFeeAmortization?: number; defaultWireTransferDelay?: Duration; defaultPayDelay?: Duration; } export interface MerchantInstanceConfig { auth: MerchantAuthConfiguration; id: string; name: string; payto_uris: string[]; address: unknown; jurisdiction: unknown; default_max_wire_fee: string; default_max_deposit_fee: string; default_wire_fee_amortization: number; default_wire_transfer_delay: Duration; default_pay_delay: Duration; } type TestStatus = "pass" | "fail" | "skip"; export interface TestRunResult { /** * Name of the test. */ name: string; /** * How long did the test run? */ timeSec: number; status: TestStatus; 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) => { console.warn( `**** received fatal process event, terminating test ${testName}`, ); gc.shutdownSync(); process.exit(1); }; process.on("SIGINT", handleSignal); process.on("SIGTERM", handleSignal); process.on("unhandledRejection", handleSignal); process.on("uncaughtException", handleSignal); try { console.log("running test in directory", gc.testDir); await Promise.race([testMain(gc), p.promise]); 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.", () => { console.error("Requested shutdown"); resolve(); }); }); rl.close(); } } catch (e) { console.error("FATAL: test failed with exception", e); status = "fail"; } finally { await gc.shutdown(); } const afterMs = new Date().getTime(); return { name: testName, timeSec: (afterMs - startMs) / 1000, status, }; } function shellWrap(s: string) { return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"; } export class WalletCli { private currentTimetravel: Duration | undefined; 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", ) { const self = this; this._client = { async call(op: any, payload: any): Promise { console.log("calling wallet with timetravel arg", self.timetravelArg); const resp = await sh( self.globalTestState, `wallet-${self.name}`, `taler-wallet-cli ${ self.timetravelArg ?? "" } --no-throttle -LTRACE --wallet-db '${ self.dbfile }' api '${op}' ${shellWrap(JSON.stringify(payload))}`, ); console.log("--- wallet core response ---"); console.log(resp); console.log("--- end of response ---"); let ar: any; try { ar = JSON.parse(resp) as CoreApiResponse; } catch (e) { throw new Error("wallet CLI did not return a proper JSON response"); } 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: { maxRetries?: number } = {}): Promise { await runCommand( this.globalTestState, `wallet-${this.name}`, "taler-wallet-cli", [ "--no-throttle", ...this.timetravelArgArr, "-LTRACE", "--wallet-db", this.dbfile, "run-until-done", ...(args.maxRetries ? ["--max-retries", `${args.maxRetries}`] : []), ], ); } async runPending(): Promise { await runCommand( this.globalTestState, `wallet-${this.name}`, "taler-wallet-cli", [ "--no-throttle", "-LTRACE", ...this.timetravelArgArr, "--wallet-db", this.dbfile, "run-pending", ], ); } } export function getRandomIban(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}`; } // Only used in one tipping test. export function getWireMethod(): string { if (process.env.WALLET_HARNESS_WITH_EUFIN) 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 getPayto(label: string): string { if (process.env.WALLET_HARNESS_WITH_EUFIN) return `payto://iban/SANDBOXX/${getRandomIban( label, )}?receiver-name=${label}`; return `payto://x-taler-bank/${label}`; } function waitMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }