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