/*
This file is part of GNU Taler
(C) 2020 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see
*/
/**
* Test harness for various GNU Taler components.
* Also provides a fault-injection proxy.
*
* @author Florian Dold
*/
/**
* Imports
*/
import {
AccountAddDetails,
AmountJson,
Amounts,
TalerCorebankApiClient,
Configuration,
CoreApiResponse,
Duration,
EddsaKeyPair,
Logger,
MerchantInstanceConfig,
PartialMerchantInstanceConfig,
TalerError,
WalletNotification,
createEddsaKeyPair,
eddsaGetPublic,
encodeCrock,
hash,
j2s,
parsePaytoUri,
stringToBytes,
} from "@gnu-taler/taler-util";
import {
HttpRequestLibrary,
createPlatformHttpLib,
expectSuccessResponseOrThrow,
} from "@gnu-taler/taler-util/http";
import {
WalletCoreApiClient,
WalletCoreRequestType,
WalletCoreResponseType,
WalletOperations,
openPromise,
} 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 { URL } from "url";
import { CoinConfig } from "./denomStructures.js";
const logger = new Logger("harness.ts");
export async function delayMs(ms: number): Promise {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(), ms);
});
}
export interface WithAuthorization {
Authorization?: string;
}
interface WaitResult {
code: number | null;
signal: NodeJS.Signals | null;
}
class CommandError extends Error {
constructor(
public message: string,
public logName: string,
public command: string,
public args: string[],
public env: Env,
public code: number | null,
) {
super(message);
}
}
interface Env {
[index: string]: string | undefined;
}
/**
* Run a shell command, return stdout.
*/
export async function sh(
t: GlobalTestState,
logName: string,
command: string,
env: Env = process.env,
): Promise {
logger.trace(`running command ${command}`);
return new Promise((resolve, reject) => {
const stdoutChunks: Buffer[] = [];
const proc = spawn(command, {
stdio: ["inherit", "pipe", "pipe"],
shell: true,
env,
});
proc.stdout.on("data", (x) => {
if (x instanceof Buffer) {
stdoutChunks.push(x);
} else {
throw Error("unexpected data chunk type");
}
});
const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
const stderrLog = fs.createWriteStream(stderrLogFileName, {
flags: "a",
});
proc.stderr.pipe(stderrLog);
proc.on("exit", (code, signal) => {
logger.info(`child process ${logName} exited (${code} / ${signal})`);
if (code != 0) {
reject(
new CommandError(
`Unexpected exit code ${code}`,
logName,
command,
[],
env,
code,
),
);
return;
}
const b = Buffer.concat(stdoutChunks).toString("utf-8");
resolve(b);
});
proc.on("error", (err) => {
reject(
new CommandError(
"Child process had error:" + err.message,
logName,
command,
[],
env,
null,
),
);
});
});
}
function shellescape(args: string[]) {
const ret = args.map((s) => {
if (/[^A-Za-z0-9_\/:=-]/.test(s)) {
s = "'" + s.replace(/'/g, "'\\''") + "'";
s = s.replace(/^(?:'')+/g, "").replace(/\\'''/g, "\\'");
}
return s;
});
return ret.join(" ");
}
/**
* Run a shell command, return stdout.
*
* Log stderr to a log file.
*/
export async function runCommand(
t: GlobalTestState,
logName: string,
command: string,
args: string[],
env: { [index: string]: string | undefined } = process.env,
): Promise {
logger.info(`running command ${shellescape([command, ...args])}`);
return new Promise((resolve, reject) => {
const stdoutChunks: Buffer[] = [];
const proc = spawn(command, args, {
stdio: ["inherit", "pipe", "pipe"],
shell: false,
env: env,
});
proc.stdout.on("data", (x) => {
if (x instanceof Buffer) {
stdoutChunks.push(x);
} else {
throw Error("unexpected data chunk type");
}
});
const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
const stderrLog = fs.createWriteStream(stderrLogFileName, {
flags: "a",
});
proc.stderr.pipe(stderrLog);
proc.on("exit", (code, signal) => {
logger.trace(`child process exited (${code} / ${signal})`);
if (code != 0) {
reject(
new CommandError(
`Unexpected exit code ${code}`,
logName,
command,
[],
env,
code,
),
);
return;
}
const b = Buffer.concat(stdoutChunks).toString("utf-8");
resolve(b);
});
proc.on("error", (err) => {
reject(
new CommandError(
"Child process had error:" + err.message,
logName,
command,
[],
env,
null,
),
);
});
});
}
export class ProcessWrapper {
private waitPromise: Promise;
constructor(public proc: ChildProcess) {
this.waitPromise = new Promise((resolve, reject) => {
proc.on("exit", (code, signal) => {
resolve({ code, signal });
});
proc.on("error", (err) => {
reject(err);
});
});
}
wait(): Promise {
return this.waitPromise;
}
}
export class GlobalTestParams {
testDir: string;
}
export class GlobalTestState {
testDir: string;
procs: ProcessWrapper[];
servers: http.Server[];
inShutdown: boolean = false;
constructor(params: GlobalTestParams) {
this.testDir = params.testDir;
this.procs = [];
this.servers = [];
}
async assertThrowsTalerErrorAsync(
block: () => Promise,
): Promise {
try {
await block();
} catch (e) {
if (e instanceof TalerError) {
return e;
}
throw Error(`expected TalerError to be thrown, but got ${e}`);
}
throw Error(
`expected TalerError to be thrown, but block finished without throwing`,
);
}
async assertThrowsAsync(block: () => Promise): Promise {
try {
await block();
} catch (e) {
return e;
}
throw Error(
`expected exception to be thrown, but block finished without throwing`,
);
}
assertTrue(b: boolean): asserts b {
if (!b) {
throw Error("test assertion failed");
}
}
assertDeepEqual(actual: any, expected: T): asserts actual is T {
deepStrictEqual(actual, expected);
}
assertAmountEquals(
amtActual: string | AmountJson,
amtExpected: string | AmountJson,
): void {
if (Amounts.cmp(amtActual, amtExpected) != 0) {
throw Error(
`test assertion failed: expected ${Amounts.stringify(
amtExpected,
)} but got ${Amounts.stringify(amtActual)}`,
);
}
}
assertAmountLeq(a: string | AmountJson, b: string | AmountJson): void {
if (Amounts.cmp(a, b) > 0) {
throw Error(
`test assertion failed: expected ${Amounts.stringify(
a,
)} to be less or equal (leq) than ${Amounts.stringify(b)}`,
);
}
}
shutdownSync(): void {
for (const s of this.servers) {
s.close();
s.removeAllListeners();
}
for (const p of this.procs) {
if (p.proc.exitCode == null) {
p.proc.kill("SIGTERM");
}
}
}
spawnService(
command: string,
args: string[],
logName: string,
env: { [index: string]: string | undefined } = process.env,
): ProcessWrapper {
logger.trace(
`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.info(`process ${logName} exited with success`);
} else {
logger.warn(`process ${logName} exited ${j2s({ code, signal })}`);
}
});
const stderrLogFileName = this.testDir + `/${logName}-stderr.log`;
const stderrLog = fs.createWriteStream(stderrLogFileName, {
flags: "a",
});
proc.stderr.pipe(stderrLog);
const stdoutLogFileName = this.testDir + `/${logName}-stdout.log`;
const stdoutLog = fs.createWriteStream(stdoutLogFileName, {
flags: "a",
});
proc.stdout.pipe(stdoutLog);
const procWrap = new ProcessWrapper(proc);
this.procs.push(procWrap);
return procWrap;
}
async shutdown(): Promise {
if (this.inShutdown) {
return;
}
if (shouldLingerInTest()) {
logger.trace("refusing to shut down, lingering was requested");
return;
}
this.inShutdown = true;
logger.trace("shutting down");
for (const s of this.servers) {
s.close();
s.removeAllListeners();
}
for (const p of this.procs) {
if (p.proc.exitCode == null) {
logger.trace(`killing process ${p.proc.pid}`);
p.proc.kill("SIGTERM");
await p.wait();
}
}
}
}
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(t: GlobalTestState): Promise {
const dbname = "taler-integrationtest";
try {
await runCommand(t, "dropdb", "dropdb", [dbname]);
} catch (e: any) {
logger.warn(`dropdb failed: ${e.toString()}`);
}
await runCommand(t, "createdb", "createdb", [dbname]);
return {
connStr: `postgres:///${dbname}`,
dbname,
};
}
/**
* Make sure that the taler-integrationtest-shared database exists.
* Don't delete it if it already exists.
*/
export async function setupSharedDb(t: GlobalTestState): Promise {
const dbname = "taler-integrationtest-shared";
const databases = await runCommand(t, "list-dbs", "psql", ["-Aqtl"]);
if (databases.indexOf("taler-integrationtest-shared") < 0) {
await runCommand(t, "createdb", "createdb", [dbname]);
}
return {
connStr: `postgres:///${dbname}`,
dbname,
};
}
export interface BankConfig {
currency: string;
httpPort: number;
database: string;
allowRegistrations: boolean;
maxDebt?: string;
overrideTestDir?: string;
}
export interface FakeBankConfig {
currency: string;
httpPort: number;
}
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.ageRestricted) {
config.setString(s, "age_restricted", "yes");
}
if (c.cipher === "RSA") {
config.setString(s, "rsa_keysize", `${c.rsaKeySize}`);
config.setString(s, "cipher", "RSA");
} else if (c.cipher === "CS") {
config.setString(s, "cipher", "CS");
} else {
throw new Error();
}
}
function backoffStart(): number {
return 10;
}
function backoffIncrement(n: number): number {
return Math.min(Math.floor(n * 1.5), 1000);
}
/**
* Send an HTTP request until it succeeds or the process dies.
*/
export async function pingProc(
proc: ProcessWrapper | undefined,
url: string,
serviceName: string,
): Promise {
if (!proc || proc.proc.exitCode !== null) {
throw Error(`service process ${serviceName} not started, can't ping`);
}
let nextDelay = backoffStart();
while (true) {
try {
logger.trace(`pinging ${serviceName} at ${url}`);
const resp = await harnessHttpLib.fetch(url);
if (resp.status !== 200) {
throw Error("non-200 status code");
}
logger.trace(`service ${serviceName} available`);
return;
} catch (e: any) {
logger.warn(`service ${serviceName} not ready:`, e.toString());
logger.info(`waiting ${nextDelay}ms on ${serviceName}`);
await delayMs(nextDelay);
nextDelay = backoffIncrement(nextDelay);
}
if (!proc || proc.proc.exitCode != null || proc.proc.signalCode != null) {
throw Error(`service process ${serviceName} stopped unexpectedly`);
}
}
}
class BankServiceBase {
proc: ProcessWrapper | undefined;
protected constructor(
protected globalTestState: GlobalTestState,
protected bankConfig: BankConfig,
protected configFile: string,
) {}
}
export interface HarnessExchangeBankAccount {
accountName: string;
accountPassword: string;
accountPaytoUri: string;
wireGatewayApiBaseUrl: string;
}
/**
* Implementation of the bank service using the "taler-fakebank-run" tool.
*/
export class FakebankService
extends BankServiceBase
implements BankServiceHandle
{
proc: ProcessWrapper | undefined;
http = createPlatformHttpLib({ enableThrottling: false });
// We store "created" accounts during setup and
// register them after startup.
private accounts: {
accountName: string;
accountPassword: string;
}[] = [];
/**
* Create a new fakebank service handle.
*
* First generates the configuration for the fakebank and
* then creates a fakebank handle, but doesn't start the fakebank
* service yet.
*/
static async create(
gc: GlobalTestState,
bc: BankConfig,
): Promise {
const config = new Configuration();
const testDir = bc.overrideTestDir ?? gc.testDir;
setTalerPaths(config, testDir + "/talerhome");
config.setString("taler", "currency", bc.currency);
config.setString("bank", "http_port", `${bc.httpPort}`);
config.setString("bank", "serve", "http");
config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`);
config.setString("bank", "ram_limit", `${1024}`);
const cfgFilename = testDir + "/bank.conf";
config.write(cfgFilename, { excludeDefaults: true });
return new FakebankService(gc, bc, cfgFilename);
}
static fromExistingConfig(
gc: GlobalTestState,
opts: { overridePath?: string },
): FakebankService {
const testDir = opts.overridePath ?? gc.testDir;
const cfgFilename = testDir + `/bank.conf`;
const config = Configuration.load(cfgFilename);
const bc: BankConfig = {
allowRegistrations:
config.getYesNo("bank", "allow_registrations").orUndefined() ?? true,
currency: config.getString("taler", "currency").required(),
database: "none",
httpPort: config.getNumber("bank", "http_port").required(),
maxDebt: config.getString("bank", "max_debt").required(),
};
return new FakebankService(gc, bc, cfgFilename);
}
setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) {
if (!!this.proc) {
throw Error("Can't set suggested exchange while bank is running.");
}
const config = Configuration.load(this.configFile);
config.setString("bank", "suggested_exchange", e.baseUrl);
config.write(this.configFile, { excludeDefaults: true });
}
get baseUrl(): string {
return `http://localhost:${this.bankConfig.httpPort}/`;
}
get corebankApiBaseUrl(): string {
return this.baseUrl;
}
async createExchangeAccount(
accountName: string,
password: string,
): Promise {
this.accounts.push({
accountName,
accountPassword: password,
});
return {
accountName: accountName,
accountPassword: password,
accountPaytoUri: generateRandomPayto(accountName),
wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/accounts/${accountName}/taler-wire-gateway/`,
};
}
get port() {
return this.bankConfig.httpPort;
}
async start(): Promise {
logger.info("starting fakebank");
if (this.proc) {
logger.info("fakebank already running, not starting again");
return;
}
this.proc = this.globalTestState.spawnService(
"taler-fakebank-run",
[
"-c",
this.configFile,
"--signup-bonus",
`${this.bankConfig.currency}:100`,
],
"bank",
);
await this.pingUntilAvailable();
const bankClient = new TalerCorebankApiClient(this.corebankApiBaseUrl);
for (const acc of this.accounts) {
await bankClient.registerAccount(acc.accountName, acc.accountPassword);
}
}
async pingUntilAvailable(): Promise {
const url = `http://localhost:${this.bankConfig.httpPort}/config`;
await pingProc(this.proc, url, "bank");
}
}
/**
* Implementation of the bank service using the "taler-fakebank-run" tool.
*/
export class LibeufinBankService
extends BankServiceBase
implements BankServiceHandle
{
proc: ProcessWrapper | undefined;
http = createPlatformHttpLib({ enableThrottling: false });
// We store "created" accounts during setup and
// register them after startup.
private accounts: {
accountName: string;
accountPassword: string;
}[] = [];
/**
* Create a new fakebank service handle.
*
* First generates the configuration for the fakebank and
* then creates a fakebank handle, but doesn't start the fakebank
* service yet.
*/
static async create(
gc: GlobalTestState,
bc: BankConfig,
): Promise {
const config = new Configuration();
const testDir = bc.overrideTestDir ?? gc.testDir;
setTalerPaths(config, testDir + "/talerhome");
config.setString("libeufin-bankdb-postgres", "config", bc.database);
config.setString("libeufin-bank", "currency", bc.currency);
config.setString("libeufin-bank", "port", `${bc.httpPort}`);
config.setString("libeufin-bank", "serve", "tcp");
config.setString(
"libeufin-bank",
"DEFAULT_CUSTOMER_DEBT_LIMIT",
`${bc.currency}:500`,
);
config.setString(
"libeufin-bank",
"DEFAULT_ADMIN_DEBT_LIMIT",
`${bc.currency}:999999`,
);
config.setString(
"libeufin-bank",
"registration_bonus",
`${bc.currency}:100`,
);
config.setString("libeufin-bank", "registration_bonus_enabled", `yes`);
config.setString("libeufin-bank", "max_auth_token_duration", "1h");
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_CUSTOMER_DEBT_LIMIT")
.required(),
};
return new FakebankService(gc, bc, cfgFilename);
}
setSuggestedExchange(e: ExchangeServiceInterface) {
if (!!this.proc) {
throw Error("Can't set suggested exchange while bank is running.");
}
const config = Configuration.load(this.configFile);
config.setString("libeufin-bank", "suggested_withdrawal_exchange", e.baseUrl);
config.write(this.configFile, { excludeDefaults: true });
}
get baseUrl(): string {
return `http://localhost:${this.bankConfig.httpPort}/`;
}
get corebankApiBaseUrl(): string {
return this.baseUrl;
}
get port() {
return this.bankConfig.httpPort;
}
async start(): Promise {
logger.info("starting libeufin-bank");
if (this.proc) {
logger.info("libeufin-bank already running, not starting again");
return;
}
await sh(
this.globalTestState,
"libeufin-bank-dbinit",
`libeufin-bank dbinit -r -c "${this.configFile}"`,
);
this.proc = this.globalTestState.spawnService(
"libeufin-bank",
["serve", "-c", this.configFile],
"libeufin-bank-httpd",
);
await this.pingUntilAvailable();
const bankClient = new TalerCorebankApiClient(this.corebankApiBaseUrl);
for (const acc of this.accounts) {
await bankClient.registerAccount(acc.accountName, acc.accountPassword);
}
}
async pingUntilAvailable(): Promise {
const url = `http://localhost:${this.bankConfig.httpPort}/config`;
await pingProc(this.proc, url, "libeufin-bank");
}
}
// Use libeufin bank instead of pybank.
const useLibeufinBank = false;
export interface BankServiceHandle {
readonly corebankApiBaseUrl: string;
readonly http: HttpRequestLibrary;
}
export type BankService = BankServiceHandle;
export const BankService = FakebankService;
export interface ExchangeConfig {
name: string;
currency: string;
roundUnit?: string;
httpPort: number;
database: string;
overrideTestDir?: string;
}
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;
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 [];
}
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"],
);
}
async runAggregatorOnce() {
try {
await runCommand(
this.globalState,
`exchange-${this.name}-aggregator-once`,
"taler-exchange-aggregator",
[...this.timetravelArgArr, "-c", this.configFilename, "-t", "-y"],
);
} catch (e) {
logger.info(
"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, { excludeDefaults: true });
}
static create(gc: GlobalTestState, e: ExchangeConfig) {
const testDir = e.overrideTestDir ?? gc.testDir;
const config = new Configuration();
setTalerPaths(config, testDir + "/talerhome");
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",
"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 });
if (fs.existsSync(masterPrivFile)) {
throw new Error(
"master priv file already exists, can't create new exchange config",
);
}
fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv));
const cfgFilename = testDir + `/exchange-${e.name}.conf`;
config.write(cfgFilename, { excludeDefaults: true });
return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey);
}
addOfferedCoins(offeredCoins: ((curr: string) => CoinConfig)[]) {
const config = Configuration.load(this.configFilename);
offeredCoins.forEach((cc) =>
setCoin(config, cc(this.exchangeConfig.currency)),
);
config.write(this.configFilename, { excludeDefaults: true });
}
addCoinConfigList(ccs: CoinConfig[]) {
const config = Configuration.load(this.configFilename);
ccs.forEach((cc) => setCoin(config, cc));
config.write(this.configFilename, { excludeDefaults: true });
}
enableAgeRestrictions(maskStr: string) {
const config = Configuration.load(this.configFilename);
config.setString("exchange-extension-age_restriction", "enabled", "yes");
config.setString(
"exchange-extension-age_restriction",
"age_groups",
maskStr,
);
config.write(this.configFilename, { excludeDefaults: true });
}
get masterPub() {
return encodeCrock(this.keyPair.eddsaPub);
}
get port() {
return this.exchangeConfig.httpPort;
}
/**
* Run a function that modifies the existing exchange configuration.
* The modified exchange configuration will then be written to the
* file system.
*/
async modifyConfig(
f: (config: Configuration) => Promise,
): Promise {
const config = Configuration.load(this.configFilename);
await f(config);
config.write(this.configFilename, { excludeDefaults: true });
}
async addBankAccount(
localName: string,
exchangeBankAccount: HarnessExchangeBankAccount,
): Promise {
const config = Configuration.load(this.configFilename);
config.setString(
`exchange-account-${localName}`,
"wire_response",
`\${TALER_DATA_HOME}/exchange/account-${localName}.json`,
);
config.setString(
`exchange-account-${localName}`,
"payto_uri",
exchangeBankAccount.accountPaytoUri,
);
config.setString(`exchange-account-${localName}`, "enable_credit", "yes");
config.setString(`exchange-account-${localName}`, "enable_debit", "yes");
config.setString(
`exchange-accountcredentials-${localName}`,
"wire_gateway_url",
exchangeBankAccount.wireGatewayApiBaseUrl,
);
config.setString(
`exchange-accountcredentials-${localName}`,
"wire_gateway_auth_method",
"basic",
);
config.setString(
`exchange-accountcredentials-${localName}`,
"username",
exchangeBankAccount.accountName,
);
config.setString(
`exchange-accountcredentials-${localName}`,
"password",
exchangeBankAccount.accountPassword,
);
config.write(this.configFilename, { excludeDefaults: true });
}
exchangeHttpProc: ProcessWrapper | undefined;
exchangeWirewatchProc: ProcessWrapper | undefined;
exchangeTransferProc: ProcessWrapper | undefined;
exchangeAggregatorProc: ProcessWrapper | undefined;
helperCryptoRsaProc: ProcessWrapper | undefined;
helperCryptoEddsaProc: ProcessWrapper | undefined;
helperCryptoCsProc: ProcessWrapper | undefined;
constructor(
private globalState: GlobalTestState,
private exchangeConfig: ExchangeConfig,
private configFilename: string,
private keyPair: EddsaKeyPair,
) {}
get name() {
return this.exchangeConfig.name;
}
get baseUrl() {
return `http://localhost:${this.exchangeConfig.httpPort}/`;
}
isRunning(): boolean {
return !!this.exchangeWirewatchProc || !!this.exchangeHttpProc;
}
/**
* Stop the wirewatch service (which runs by default).
*
* Useful for some tests.
*/
async stopWirewatch(): Promise {
const wirewatch = this.exchangeWirewatchProc;
if (wirewatch) {
wirewatch.proc.kill("SIGTERM");
await wirewatch.wait();
this.exchangeWirewatchProc = undefined;
}
}
async startWirewatch(): Promise {
const wirewatch = this.exchangeWirewatchProc;
if (wirewatch) {
logger.warn("wirewatch already running");
} else {
this.internalCreateWirewatchProc();
}
}
async stop(): Promise {
const wirewatch = this.exchangeWirewatchProc;
if (wirewatch) {
wirewatch.proc.kill("SIGTERM");
await wirewatch.wait();
this.exchangeWirewatchProc = undefined;
}
const aggregatorProc = this.exchangeAggregatorProc;
if (aggregatorProc) {
aggregatorProc.proc.kill("SIGTERM");
await aggregatorProc.wait();
this.exchangeAggregatorProc = undefined;
}
const transferProc = this.exchangeTransferProc;
if (transferProc) {
transferProc.proc.kill("SIGTERM");
await transferProc.wait();
this.exchangeTransferProc = undefined;
}
const httpd = this.exchangeHttpProc;
if (httpd) {
httpd.proc.kill("SIGTERM");
await httpd.wait();
this.exchangeHttpProc = undefined;
}
const cryptoRsa = this.helperCryptoRsaProc;
if (cryptoRsa) {
cryptoRsa.proc.kill("SIGTERM");
await cryptoRsa.wait();
this.helperCryptoRsaProc = undefined;
}
const cryptoEddsa = this.helperCryptoEddsaProc;
if (cryptoEddsa) {
cryptoEddsa.proc.kill("SIGTERM");
await cryptoEddsa.wait();
this.helperCryptoRsaProc = undefined;
}
const cryptoCs = this.helperCryptoCsProc;
if (cryptoCs) {
cryptoCs.proc.kill("SIGTERM");
await cryptoCs.wait();
this.helperCryptoCsProc = undefined;
}
}
/**
* Update keys signing the keys generated by the security module
* with the offline signing key.
*/
async keyup(): Promise {
await runCommand(
this.globalState,
"exchange-offline",
"taler-exchange-offline",
["-c", this.configFilename, "download", "sign", "upload"],
);
const 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);
}
}
const accountsDescription = accounts.map((acc) => ` * ${acc}`).join("\n");
logger.info("configuring bank accounts:");
logger.info(accountsDescription);
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",
// Year
`${i}`,
// Wire method
accTargetType,
// Wire fee
`${this.exchangeConfig.currency}:0.01`,
// Closing fee
`${this.exchangeConfig.currency}:0.01`,
"upload",
],
);
}
}
await runCommand(
this.globalState,
"exchange-offline",
"taler-exchange-offline",
[
"-c",
this.configFilename,
"global-fee",
// year
"now",
// history fee
`${this.exchangeConfig.currency}:0.01`,
// account fee
`${this.exchangeConfig.currency}:0.01`,
// purse fee
`${this.exchangeConfig.currency}:0.00`,
// purse timeout
"1h",
// history expiration
"1year",
// free purses per account
"5",
"upload",
],
);
}
async revokeDenomination(denomPubHash: string) {
if (!this.isRunning()) {
throw Error("exchange must be running when revoking denominations");
}
await runCommand(
this.globalState,
"exchange-offline",
"taler-exchange-offline",
[
"-c",
this.configFilename,
"revoke-denomination",
denomPubHash,
"upload",
],
);
}
async purgeSecmodKeys(): Promise {
const cfg = Configuration.load(this.configFilename);
const rsaKeydir = cfg
.getPath("taler-exchange-secmod-rsa", "KEY_DIR")
.required();
const eddsaKeydir = cfg
.getPath("taler-exchange-secmod-eddsa", "KEY_DIR")
.required();
// Be *VERY* careful when changing this, or you will accidentally delete user data.
await sh(this.globalState, "rm-secmod-keys", `rm -rf ${rsaKeydir}/COIN_*`);
await sh(this.globalState, "rm-secmod-keys", `rm ${eddsaKeydir}/*`);
}
async purgeDatabase(): Promise {
await sh(
this.globalState,
"exchange-dbinit",
`taler-exchange-dbinit -r -c "${this.configFilename}"`,
);
}
private internalCreateWirewatchProc() {
this.exchangeWirewatchProc = this.globalState.spawnService(
"taler-exchange-wirewatch",
[
"-c",
this.configFilename,
"--longpoll-timeout=5s",
...this.timetravelArgArr,
],
`exchange-wirewatch-${this.name}`,
);
}
private internalCreateAggregatorProc() {
this.exchangeAggregatorProc = this.globalState.spawnService(
"taler-exchange-aggregator",
["-c", this.configFilename, ...this.timetravelArgArr],
`exchange-aggregator-${this.name}`,
);
}
private internalCreateTransferProc() {
this.exchangeTransferProc = this.globalState.spawnService(
"taler-exchange-transfer",
["-c", this.configFilename, ...this.timetravelArgArr],
`exchange-transfer-${this.name}`,
);
}
async dbinit() {
await sh(
this.globalState,
"exchange-dbinit",
`taler-exchange-dbinit -c "${this.configFilename}"`,
);
}
async start(
opts: { skipDbinit?: boolean; skipKeyup?: boolean } = {},
): Promise {
if (this.isRunning()) {
throw Error("exchange is already running");
}
const skipDbinit = opts.skipDbinit ?? false;
if (!skipDbinit) {
await this.dbinit();
}
this.helperCryptoEddsaProc = this.globalState.spawnService(
"taler-exchange-secmod-eddsa",
["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
`exchange-crypto-eddsa-${this.name}`,
);
this.helperCryptoCsProc = this.globalState.spawnService(
"taler-exchange-secmod-cs",
["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
`exchange-crypto-cs-${this.name}`,
);
this.helperCryptoRsaProc = this.globalState.spawnService(
"taler-exchange-secmod-rsa",
["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
`exchange-crypto-rsa-${this.name}`,
);
this.internalCreateWirewatchProc();
this.internalCreateTransferProc();
this.internalCreateAggregatorProc();
this.exchangeHttpProc = this.globalState.spawnService(
"taler-exchange-httpd",
["-LINFO", "-c", this.configFilename, ...this.timetravelArgArr],
`exchange-httpd-${this.name}`,
);
await this.pingUntilAvailable();
const skipKeyup = opts.skipKeyup ?? false;
if (!skipKeyup) {
await this.keyup();
} else {
logger.info("skipping keyup");
}
}
async pingUntilAvailable(): Promise {
// We request /management/keys, since /keys can block
// when we didn't do the key setup yet.
const url = `http://localhost:${this.exchangeConfig.httpPort}/management/keys`;
await pingProc(this.exchangeHttpProc, url, `exchange (${this.name})`);
}
}
export interface MerchantConfig {
name: string;
currency: string;
httpPort: number;
database: string;
overrideTestDir?: string;
}
export interface MerchantServiceInterface {
makeInstanceBaseUrl(instanceName?: string): string;
readonly port: number;
readonly name: string;
}
/**
* Default HTTP client handle for the integration test harness.
*/
export const harnessHttpLib = createPlatformHttpLib({
enableThrottling: false,
});
export class MerchantService implements MerchantServiceInterface {
static fromExistingConfig(
gc: GlobalTestState,
name: string,
opts: { overridePath?: string },
) {
const testDir = opts.overridePath ?? gc.testDir;
const cfgFilename = testDir + `/merchant-${name}.conf`;
const config = Configuration.load(cfgFilename);
const mc: MerchantConfig = {
currency: config.getString("taler", "currency").required(),
database: config.getString("merchantdb-postgres", "config").required(),
httpPort: config.getNumber("merchant", "port").required(),
name,
};
return new MerchantService(gc, mc, cfgFilename);
}
proc: ProcessWrapper | undefined;
constructor(
private globalState: GlobalTestState,
private merchantConfig: MerchantConfig,
private configFilename: string,
) {}
private currentTimetravelOffsetMs: number | undefined;
private isRunning(): boolean {
return !!this.proc;
}
setTimetravel(t: number | undefined): void {
if (this.isRunning()) {
throw Error("can't set time travel while the exchange is running");
}
this.currentTimetravelOffsetMs = t;
}
private get timetravelArg(): string | undefined {
if (this.currentTimetravelOffsetMs != null) {
// Convert to microseconds
return `--timetravel=+${this.currentTimetravelOffsetMs * 1000}`;
}
return undefined;
}
/**
* Return an empty array if no time travel is set,
* and an array with the time travel command line argument
* otherwise.
*/
private get timetravelArgArr(): string[] {
const tta = this.timetravelArg;
if (tta) {
return [tta];
}
return [];
}
get port(): number {
return this.merchantConfig.httpPort;
}
get name(): string {
return this.merchantConfig.name;
}
async stop(): Promise {
const httpd = this.proc;
if (httpd) {
httpd.proc.kill("SIGTERM");
await httpd.wait();
this.proc = undefined;
}
}
async dbinit() {
await runCommand(
this.globalState,
"merchant-dbinit",
"taler-merchant-dbinit",
["-c", this.configFilename],
);
}
/**
* Start the merchant,
*/
async start(opts: { skipDbinit?: boolean } = {}): Promise {
const skipSetup = opts.skipDbinit ?? false;
if (!skipSetup) {
await this.dbinit();
}
this.proc = this.globalState.spawnService(
"taler-merchant-httpd",
[
"taler-merchant-httpd",
"-LDEBUG",
"-c",
this.configFilename,
...this.timetravelArgArr,
],
`merchant-${this.merchantConfig.name}`,
);
}
static async create(
gc: GlobalTestState,
mc: MerchantConfig,
): Promise {
const testDir = mc.overrideTestDir ?? gc.testDir;
const config = new Configuration();
config.setString("taler", "currency", mc.currency);
const cfgFilename = testDir + `/merchant-${mc.name}.conf`;
setTalerPaths(config, testDir + "/talerhome");
config.setString("merchant", "serve", "tcp");
config.setString("merchant", "port", `${mc.httpPort}`);
config.setString(
"merchant",
"keyfile",
"${TALER_DATA_HOME}/merchant/merchant.priv",
);
config.setString("merchantdb-postgres", "config", mc.database);
config.write(cfgFilename, { excludeDefaults: true });
return new MerchantService(gc, mc, cfgFilename);
}
addExchange(e: ExchangeServiceInterface): void {
const config = Configuration.load(this.configFilename);
config.setString(
`merchant-exchange-${e.name}`,
"exchange_base_url",
e.baseUrl,
);
config.setString(
`merchant-exchange-${e.name}`,
"currency",
this.merchantConfig.currency,
);
config.setString(`merchant-exchange-${e.name}`, "master_key", e.masterPub);
config.write(this.configFilename, { excludeDefaults: true });
}
async addDefaultInstance(): Promise {
return await this.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
paytoUris: [generateRandomPayto("merchant-default")],
auth: {
method: "external",
},
});
}
/**
* Add an instance together with a wire account.
*/
async addInstanceWithWireAccount(
instanceConfig: PartialMerchantInstanceConfig,
): Promise {
if (!this.proc) {
throw Error("merchant must be running to add instance");
}
logger.info(`adding instance '${instanceConfig.id}'`);
const url = `http://localhost:${this.merchantConfig.httpPort}/management/instances`;
const auth = instanceConfig.auth ?? { method: "external" };
const body: MerchantInstanceConfig = {
auth,
id: instanceConfig.id,
name: instanceConfig.name,
address: instanceConfig.address ?? {},
jurisdiction: instanceConfig.jurisdiction ?? {},
// FIXME: In some tests, we might want to make this configurable
use_stefan: true,
default_wire_transfer_delay:
instanceConfig.defaultWireTransferDelay ??
Duration.toTalerProtocolDuration(
Duration.fromSpec({
days: 1,
}),
),
default_pay_delay:
instanceConfig.defaultPayDelay ??
Duration.toTalerProtocolDuration(Duration.getForever()),
};
const resp = await harnessHttpLib.fetch(url, { method: "POST", body });
await expectSuccessResponseOrThrow(resp);
const accountCreateUrl = `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceConfig.id}/private/accounts`;
for (const paytoUri of instanceConfig.paytoUris) {
const accountReq: AccountAddDetails = {
payto_uri: paytoUri,
};
const acctResp = await harnessHttpLib.fetch(accountCreateUrl, {
method: "POST",
body: accountReq,
});
await expectSuccessResponseOrThrow(acctResp);
}
}
makeInstanceBaseUrl(instanceName?: string): string {
if (instanceName === undefined || instanceName === "default") {
return `http://localhost:${this.merchantConfig.httpPort}/`;
} else {
return `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/`;
}
}
async pingUntilAvailable(): Promise {
const url = `http://localhost:${this.merchantConfig.httpPort}/config`;
await pingProc(this.proc, url, `merchant (${this.merchantConfig.name})`);
}
}
type TestStatus = "pass" | "fail" | "skip";
export interface TestRunResult {
/**
* Name of the test.
*/
name: string;
/**
* How long did the test run?
*/
timeSec: number;
status: TestStatus;
reason?: string;
}
export async function runTestWithState(
gc: GlobalTestState,
testMain: (t: GlobalTestState) => Promise,
testName: string,
linger: boolean = false,
): Promise {
const startMs = new Date().getTime();
const p = openPromise();
let status: TestStatus;
const handleSignal = (s: string) => {
logger.warn(
`**** received fatal process event (${s}), terminating test ${testName}`,
);
gc.shutdownSync();
process.exit(1);
};
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
process.on("unhandledRejection", (reason: unknown, promise: any) => {
logger.warn(
`**** received unhandled rejection (${reason}), terminating test ${testName}`,
);
logger.warn(`reason type: ${typeof reason}`);
gc.shutdownSync();
process.exit(1);
});
process.on("uncaughtException", (error, origin) => {
logger.warn(
`**** received uncaught exception (${error}), terminating test ${testName}`,
);
console.warn("stack", error.stack);
gc.shutdownSync();
process.exit(1);
});
try {
logger.info("running test in directory", gc.testDir);
await Promise.race([testMain(gc), p.promise]);
logger.info("completed test in directory", gc.testDir);
status = "pass";
if (linger) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
await new Promise((resolve, reject) => {
rl.question("Press enter to shut down test.", () => {
logger.error("Requested shutdown");
resolve();
});
});
rl.close();
}
} catch (e) {
if (e instanceof CommandError) {
console.error("FATAL: test failed for", e.logName);
const errorLog = fs.readFileSync(
path.join(gc.testDir, `${e.logName}-stderr.log`),
);
console.error(`${e.message}: "${e.command}"`);
console.error(errorLog.toString());
console.error(e);
} else if (e instanceof TalerError) {
console.error(
"FATAL: test failed",
e.message,
`error detail: ${j2s(e.errorDetail)}`,
);
console.error(e.stack);
} else {
console.error("FATAL: test failed with exception", e);
}
status = "fail";
} finally {
await gc.shutdown();
}
const afterMs = new Date().getTime();
return {
name: testName,
timeSec: (afterMs - startMs) / 1000,
status,
};
}
function shellWrap(s: string) {
return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'";
}
export interface WalletCliOpts {
cryptoWorkerType?: "sync" | "node-worker-thread";
}
function tryUnixConnect(socketPath: string): Promise {
return new Promise((resolve, reject) => {
const client = net.createConnection(socketPath);
client.on("error", (e) => {
reject(e);
});
client.on("connect", () => {
client.end();
resolve();
});
});
}
export interface WalletServiceOptions {
useInMemoryDb?: boolean;
name: string;
}
export class WalletService {
walletProc: ProcessWrapper | undefined;
constructor(
private globalState: GlobalTestState,
private opts: WalletServiceOptions,
) {}
get socketPath() {
const unixPath = path.join(
this.globalState.testDir,
`${this.opts.name}.sock`,
);
return unixPath;
}
get dbPath() {
return path.join(
this.globalState.testDir,
`walletdb-${this.opts.name}.json`,
);
}
async stop(): Promise {
if (this.walletProc) {
this.walletProc.proc.kill("SIGTERM");
await this.walletProc.wait();
}
}
async start(): Promise {
let dbPath: string;
if (this.opts.useInMemoryDb) {
dbPath = ":memory:";
} else {
dbPath = path.join(
this.globalState.testDir,
`walletdb-${this.opts.name}.json`,
);
}
const unixPath = this.socketPath;
this.walletProc = this.globalState.spawnService(
"taler-wallet-cli",
[
"--wallet-db",
dbPath,
"-LTRACE", // FIXME: Make this configurable?
"--no-throttle", // FIXME: Optionally do throttling for some tests?
"advanced",
"serve",
"--unix-path",
unixPath,
],
`wallet-${this.opts.name}`,
);
logger.info(
`hint: connect to wallet using taler-wallet-cli --wallet-connection=${unixPath}`,
);
}
async pingUntilAvailable(): Promise {
let nextDelay = backoffStart();
while (1) {
try {
await tryUnixConnect(this.socketPath);
} catch (e) {
logger.info(`wallet connection attempt failed: ${e}`);
logger.info(`waiting on wallet for ${nextDelay}ms`);
await delayMs(nextDelay);
nextDelay = backoffIncrement(nextDelay);
continue;
}
logger.info("connection to wallet-core succeeded");
break;
}
}
}
export interface WalletClientArgs {
unixPath: string;
onNotification?(n: WalletNotification): void;
}
export type CancelFn = () => void;
export type NotificationHandler = (n: WalletNotification) => void;
/**
* Convenience wrapper around a (remote) wallet handle.
*/
export class WalletClient {
remoteWallet: RemoteWallet | undefined = undefined;
private waiter: WalletNotificationWaiter = makeNotificationWaiter();
notificationHandlers: NotificationHandler[] = [];
addNotificationListener(f: NotificationHandler): CancelFn {
this.notificationHandlers.push(f);
return () => {
const idx = this.notificationHandlers.indexOf(f);
if (idx >= 0) {
this.notificationHandlers.splice(idx, 1);
}
};
}
async call(
operation: Op,
payload: WalletCoreRequestType,
): Promise> {
if (!this.remoteWallet) {
throw Error("wallet not connected");
}
const client = getClientFromRemoteWallet(this.remoteWallet);
return client.call(operation, payload);
}
constructor(private args: WalletClientArgs) {}
async connect(): Promise {
const waiter = this.waiter;
const walletClient = this;
const w = await createRemoteWallet({
socketFilename: this.args.unixPath,
notificationHandler(n) {
if (walletClient.args.onNotification) {
walletClient.args.onNotification(n);
}
waiter.notify(n);
for (const h of walletClient.notificationHandlers) {
h(n);
}
},
});
this.remoteWallet = w;
this.waiter.waitForNotificationCond;
}
get client() {
if (!this.remoteWallet) {
throw Error("wallet not connected");
}
return getClientFromRemoteWallet(this.remoteWallet);
}
waitForNotificationCond(
cond: (n: WalletNotification) => T | undefined | false,
): Promise {
return this.waiter.waitForNotificationCond(cond);
}
}
export class WalletCli {
private currentTimetravel: Duration | undefined;
private _client: WalletCoreApiClient;
setTimetravel(d: Duration | undefined) {
this.currentTimetravel = d;
}
private get timetravelArg(): string | undefined {
if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
// Convert to microseconds
return `--timetravel=${this.currentTimetravel.d_ms * 1000}`;
}
return undefined;
}
constructor(
private globalTestState: GlobalTestState,
private name: string = "default",
cliOpts: WalletCliOpts = {},
) {
const self = this;
this._client = {
async call(op: any, payload: any): Promise {
logger.info(
`calling wallet with timetravel arg ${j2s(self.timetravelArg)}`,
);
const cryptoWorkerArg = cliOpts.cryptoWorkerType
? `--crypto-worker=${cliOpts.cryptoWorkerType}`
: "";
const logName = `wallet-${self.name}`;
const command = `taler-wallet-cli ${
self.timetravelArg ?? ""
} ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${
self.dbfile
}' api '${op}' ${shellWrap(JSON.stringify(payload))}`;
const resp = await sh(self.globalTestState, logName, command);
logger.info("--- wallet core response ---");
logger.info(resp);
logger.info("--- end of response ---");
let ar: CoreApiResponse;
try {
ar = JSON.parse(resp);
} catch (e) {
throw new CommandError(
"wallet CLI did not return a proper JSON response",
logName,
command,
[],
{},
null,
);
}
if (ar.type === "error") {
throw TalerError.fromUncheckedDetail(ar.error);
}
return ar.result;
},
};
}
get dbfile(): string {
return this.globalTestState.testDir + `/walletdb-${this.name}.json`;
}
deleteDatabase() {
fs.unlinkSync(this.dbfile);
}
private get timetravelArgArr(): string[] {
const tta = this.timetravelArg;
if (tta) {
return [tta];
}
return [];
}
get client(): WalletCoreApiClient {
return this._client;
}
async runUntilDone(args: { maxRetries?: number } = {}): Promise {
await runCommand(
this.globalTestState,
`wallet-${this.name}`,
"taler-wallet-cli",
[
"--no-throttle",
...this.timetravelArgArr,
"-LTRACE",
"--skip-defaults",
"--wallet-db",
this.dbfile,
"run-until-done",
...(args.maxRetries ? ["--max-retries", `${args.maxRetries}`] : []),
],
);
}
async runPending(): Promise {
await runCommand(
this.globalTestState,
`wallet-${this.name}`,
"taler-wallet-cli",
[
"--no-throttle",
"--skip-defaults",
"-LTRACE",
...this.timetravelArgArr,
"--wallet-db",
this.dbfile,
"advanced",
"run-pending",
],
);
}
}
export function generateRandomTestIban(salt: string | null = null): string {
function getBban(salt: string | null): string {
if (!salt) return Math.random().toString().substring(2, 6);
let hashed = hash(stringToBytes(salt));
let ret = "";
for (let i = 0; i < hashed.length; i++) {
ret += hashed[i].toString();
}
return ret.substring(0, 4);
}
let cc_no_check = "131400"; // == DE00
let bban = getBban(salt);
let check_digits = (
98 -
(Number.parseInt(`${bban}${cc_no_check}`) % 97)
).toString();
if (check_digits.length == 1) {
check_digits = `0${check_digits}`;
}
return `DE${check_digits}${bban}`;
}
export function getWireMethodForTest(): string {
if (useLibeufinBank) return "iban";
return "x-taler-bank";
}
/**
* Generate a payto address, whose authority depends
* on whether the banking is served by euFin or Pybank.
*/
export function generateRandomPayto(label: string): string {
if (useLibeufinBank)
return `payto://iban/SANDBOXX/${generateRandomTestIban(
label,
)}?receiver-name=${label}`;
return `payto://x-taler-bank/localhost/${label}`;
}
function waitMs(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}