/*
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
*/
/**
* Helpers to create typical test environments.
*
* @author Florian Dold
*/
/**
* Imports
*/
import {
AmountString,
ConfirmPayResultType,
Duration,
Logger,
MerchantApiClient,
MerchantContractTerms,
NotificationType,
PartialWalletRunConfig,
PreparePayResultType,
TalerCorebankApiClient,
TransactionMajorState,
WalletNotification,
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { CoinConfig, defaultCoinConfig } from "./denomStructures.js";
import {
FaultInjectedExchangeService,
FaultInjectedMerchantService,
} from "./faultInjection.js";
import {
BankService,
DbInfo,
ExchangeService,
ExchangeServiceInterface,
FakebankService,
GlobalTestState,
HarnessExchangeBankAccount,
MerchantService,
MerchantServiceInterface,
WalletCli,
WalletClient,
WalletService,
WithAuthorization,
generateRandomPayto,
setupDb,
setupSharedDb,
} from "./harness.js";
import * as fs from "fs";
const logger = new Logger("helpers.ts");
/**
* @deprecated
*/
export interface SimpleTestEnvironment {
commonDb: DbInfo;
bank: BankService;
exchange: ExchangeService;
exchangeBankAccount: HarnessExchangeBankAccount;
merchant: MerchantService;
wallet: WalletCli;
}
/**
* Improved version of the simple test environment,
* with the daemonized wallet.
*/
export interface SimpleTestEnvironmentNg {
commonDb: DbInfo;
bank: BankService;
exchange: ExchangeService;
exchangeBankAccount: HarnessExchangeBankAccount;
merchant: MerchantService;
walletClient: WalletClient;
walletService: WalletService;
}
export interface EnvOptions {
/**
* If provided, enable age restrictions with the specified age mask string.
*/
ageMaskSpec?: string;
mixedAgeRestriction?: boolean;
additionalExchangeConfig?(e: ExchangeService): void;
additionalMerchantConfig?(m: MerchantService): void;
additionalBankConfig?(b: BankService): void;
}
export function getSharedTestDir(): string {
return `/tmp/taler-harness@${process.env.USER}`;
}
export async function useSharedTestkudosEnvironment(t: GlobalTestState) {
const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
const sharedDir = getSharedTestDir();
fs.mkdirSync(sharedDir, { recursive: true });
const db = await setupSharedDb(t);
let bank: FakebankService;
const prevSetupDone = fs.existsSync(sharedDir + "/setup-done");
logger.info(`previous setup done: ${prevSetupDone}`);
// Wallet has longer startup-time and no dependencies,
// so we start it rather early.
const walletStartProm = createWalletDaemonWithClient(t, { name: "wallet" });
if (fs.existsSync(sharedDir + "/bank.conf")) {
logger.info("reusing existing bank");
bank = BankService.fromExistingConfig(t, {
overridePath: sharedDir,
});
} else {
logger.info("creating new bank config");
bank = await BankService.create(t, {
allowRegistrations: true,
currency: "TESTKUDOS",
database: db.connStr,
httpPort: 8082,
overrideTestDir: sharedDir,
});
}
logger.info("setting up exchange");
const exchangeName = "testexchange-1";
const exchangeConfigFilename = sharedDir + `/exchange-${exchangeName}.conf`;
logger.info(`exchange config filename: ${exchangeConfigFilename}`);
let exchange: ExchangeService;
if (fs.existsSync(exchangeConfigFilename)) {
logger.info("reusing existing exchange config");
exchange = ExchangeService.fromExistingConfig(t, exchangeName, {
overridePath: sharedDir,
});
} else {
logger.info("creating new exchange config");
exchange = ExchangeService.create(t, {
name: "testexchange-1",
currency: "TESTKUDOS",
httpPort: 8081,
database: db.connStr,
overrideTestDir: sharedDir,
});
}
logger.info("setting up merchant");
let merchant: MerchantService;
const merchantName = "testmerchant-1";
const merchantConfigFilename = sharedDir + `/merchant-${merchantName}}`;
if (fs.existsSync(merchantConfigFilename)) {
merchant = MerchantService.fromExistingConfig(t, merchantName, {
overridePath: sharedDir,
});
} else {
merchant = await MerchantService.create(t, {
name: "testmerchant-1",
currency: "TESTKUDOS",
httpPort: 8083,
database: db.connStr,
overrideTestDir: sharedDir,
});
}
logger.info("creating bank account for exchange");
const exchangeBankAccount = await bank.createExchangeAccount(
"myexchange",
"x",
);
logger.info("creating exchange bank account");
await exchange.addBankAccount("1", exchangeBankAccount);
bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
exchange.addCoinConfigList(coinConfig);
merchant.addExchange(exchange);
logger.info("basic setup done, starting services");
if (!prevSetupDone) {
// Must be done sequentially, due to a concurrency
// issue in the *-dbinit tools.
await exchange.dbinit();
await merchant.dbinit();
}
const bankStart = async () => {
await bank.start();
await bank.pingUntilAvailable();
};
const exchangeStart = async () => {
await exchange.start({
skipDbinit: true,
skipKeyup: prevSetupDone,
});
await exchange.pingUntilAvailable();
};
const merchStart = async () => {
await merchant.start({
skipDbinit: true,
});
await merchant.pingUntilAvailable();
if (!prevSetupDone) {
await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
paytoUris: [generateRandomPayto("merchant-default")],
defaultWireTransferDelay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ minutes: 1 }),
),
});
await merchant.addInstanceWithWireAccount({
id: "minst1",
name: "minst1",
paytoUris: [generateRandomPayto("minst1")],
defaultWireTransferDelay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ minutes: 1 }),
),
});
}
};
await bankStart();
const res = await Promise.all([
exchangeStart(),
merchStart(),
undefined,
walletStartProm,
]);
const walletClient = res[3].walletClient;
const walletService = res[3].walletService;
fs.writeFileSync(sharedDir + "/setup-done", "OK");
logger.info("setup done!");
return {
commonDb: db,
exchange,
merchant,
walletClient,
walletService,
bank,
exchangeBankAccount,
};
}
/**
* Run a test case with a simple TESTKUDOS Taler environment, consisting
* of one exchange, one bank and one merchant.
*
* V2 uses a daemonized wallet instead of the CLI wallet.
*/
export async function createSimpleTestkudosEnvironmentV2(
t: GlobalTestState,
coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
opts: EnvOptions = {},
): Promise {
const db = await setupDb(t);
const bank = await BankService.create(t, {
allowRegistrations: true,
currency: "TESTKUDOS",
database: db.connStr,
httpPort: 8082,
});
const exchange = ExchangeService.create(t, {
name: "testexchange-1",
currency: "TESTKUDOS",
httpPort: 8081,
database: db.connStr,
});
const merchant = await MerchantService.create(t, {
name: "testmerchant-1",
currency: "TESTKUDOS",
httpPort: 8083,
database: db.connStr,
});
const exchangeBankAccount = await bank.createExchangeAccount(
"myexchange",
"x",
);
await exchange.addBankAccount("1", exchangeBankAccount);
bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
if (opts.additionalBankConfig) {
opts.additionalBankConfig(bank);
}
await bank.start();
await bank.pingUntilAvailable();
const ageMaskSpec = opts.ageMaskSpec;
if (ageMaskSpec) {
exchange.enableAgeRestrictions(ageMaskSpec);
// Enable age restriction for all coins.
exchange.addCoinConfigList(
coinConfig.map((x) => ({
...x,
name: `${x.name}-age`,
ageRestricted: true,
})),
);
// For mixed age restrictions, we also offer coins without age restrictions
if (opts.mixedAgeRestriction) {
exchange.addCoinConfigList(
coinConfig.map((x) => ({ ...x, ageRestricted: false })),
);
}
} else {
exchange.addCoinConfigList(coinConfig);
}
if (opts.additionalExchangeConfig) {
opts.additionalExchangeConfig(exchange);
}
await exchange.start();
await exchange.pingUntilAvailable();
merchant.addExchange(exchange);
if (opts.additionalMerchantConfig) {
opts.additionalMerchantConfig(merchant);
}
await merchant.start();
await merchant.pingUntilAvailable();
await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
paytoUris: [generateRandomPayto("merchant-default")],
defaultWireTransferDelay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ minutes: 1 }),
),
});
await merchant.addInstanceWithWireAccount({
id: "minst1",
name: "minst1",
paytoUris: [generateRandomPayto("minst1")],
defaultWireTransferDelay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ minutes: 1 }),
),
});
const { walletClient, walletService } = await createWalletDaemonWithClient(
t,
{ name: "wallet", persistent: true },
);
console.log("setup done!");
return {
commonDb: db,
exchange,
merchant,
walletClient,
walletService,
bank,
exchangeBankAccount,
};
}
export interface CreateWalletArgs {
handleNotification?(wn: WalletNotification): void;
name: string;
persistent?: boolean;
overrideDbPath?: string;
config?: PartialWalletRunConfig;
}
export async function createWalletDaemonWithClient(
t: GlobalTestState,
args: CreateWalletArgs,
): Promise<{ walletClient: WalletClient; walletService: WalletService }> {
const walletService = new WalletService(t, {
name: args.name,
useInMemoryDb: !args.persistent,
overrideDbPath: args.overrideDbPath,
});
await walletService.start();
await walletService.pingUntilAvailable();
const observabilityEventFile = t.testDir + `/wallet-${args.name}-notifs.log`;
const onNotif = (notif: WalletNotification) => {
if (observabilityEventFile) {
fs.appendFileSync(
observabilityEventFile,
new Date().toISOString() + " " + JSON.stringify(notif) + "\n",
);
}
if (args.handleNotification) {
args.handleNotification(notif);
}
};
const walletClient = new WalletClient({
name: args.name,
unixPath: walletService.socketPath,
onNotification: onNotif,
});
await walletClient.connect();
const defaultRunConfig = {
testing: {
skipDefaults: true,
emitObservabilityEvents: !!process.env["TALER_TEST_OBSERVABILITY"],
},
} satisfies PartialWalletRunConfig;
await walletClient.client.call(WalletApiOperation.InitWallet, {
config: args.config ?? defaultRunConfig,
});
return { walletClient, walletService };
}
export interface FaultyMerchantTestEnvironment {
commonDb: DbInfo;
bank: BankService;
exchange: ExchangeService;
faultyExchange: FaultInjectedExchangeService;
exchangeBankAccount: HarnessExchangeBankAccount;
merchant: MerchantService;
faultyMerchant: FaultInjectedMerchantService;
walletClient: WalletClient;
}
/**
* Run a test case with a simple TESTKUDOS Taler environment, consisting
* of one exchange, one bank and one merchant.
*/
export async function createFaultInjectedMerchantTestkudosEnvironment(
t: GlobalTestState,
): Promise {
const db = await setupDb(t);
const bank = await BankService.create(t, {
allowRegistrations: true,
currency: "TESTKUDOS",
database: db.connStr,
httpPort: 8082,
});
const exchange = ExchangeService.create(t, {
name: "testexchange-1",
currency: "TESTKUDOS",
httpPort: 8081,
database: db.connStr,
});
const merchant = await MerchantService.create(t, {
name: "testmerchant-1",
currency: "TESTKUDOS",
httpPort: 8083,
database: db.connStr,
});
const faultyMerchant = new FaultInjectedMerchantService(t, merchant, 9083);
const faultyExchange = new FaultInjectedExchangeService(t, exchange, 9081);
// Base URL must contain port that the proxy is listening on.
await exchange.modifyConfig(async (config) => {
config.setString("exchange", "base_url", "http://localhost:9081/");
});
const exchangeBankAccount = await bank.createExchangeAccount(
"myexchange",
"x",
);
exchange.addBankAccount("1", exchangeBankAccount);
bank.setSuggestedExchange(
faultyExchange,
exchangeBankAccount.accountPaytoUri,
);
await bank.start();
await bank.pingUntilAvailable();
exchange.addOfferedCoins(defaultCoinConfig);
await exchange.start();
await exchange.pingUntilAvailable();
merchant.addExchange(faultyExchange);
await merchant.start();
await merchant.pingUntilAvailable();
await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
paytoUris: [generateRandomPayto("merchant-default")],
});
await merchant.addInstanceWithWireAccount({
id: "minst1",
name: "minst1",
paytoUris: [generateRandomPayto("minst1")],
});
console.log("setup done!");
const { walletClient } = await createWalletDaemonWithClient(t, {
name: "default",
});
return {
commonDb: db,
exchange,
merchant,
walletClient,
bank,
exchangeBankAccount,
faultyMerchant,
faultyExchange,
};
}
export interface WithdrawViaBankResult {
withdrawalFinishedCond: Promise;
}
/**
* Withdraw via a bank with the testing API enabled.
* Uses the new notification-based mechanism to wait for the
* operation to finish.
*/
export async function withdrawViaBankV2(
t: GlobalTestState,
p: {
walletClient: WalletClient;
bank: BankService;
exchange: ExchangeServiceInterface;
amount: AmountString | string;
restrictAge?: number;
},
): Promise {
const { walletClient: wallet, bank, exchange, amount } = p;
const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl);
const user = await bankClient.createRandomBankUser();
const wop = await bankClient.createWithdrawalOperation(user.username, amount);
// Hand it to the wallet
await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
talerWithdrawUri: wop.taler_withdraw_uri,
restrictAge: p.restrictAge,
});
// Withdraw (AKA select)
const acceptRes = await wallet.client.call(
WalletApiOperation.AcceptBankIntegratedWithdrawal,
{
exchangeBaseUrl: exchange.baseUrl,
talerWithdrawUri: wop.taler_withdraw_uri,
restrictAge: p.restrictAge,
},
);
const withdrawalFinishedCond = wallet.waitForNotificationCond(
(x) =>
x.type === NotificationType.TransactionStateTransition &&
x.newTxState.major === TransactionMajorState.Done &&
x.transactionId === acceptRes.transactionId,
);
// Confirm it
await bankClient.confirmWithdrawalOperation(user.username, {
withdrawalOperationId: wop.withdrawal_id,
});
return {
withdrawalFinishedCond,
};
}
export async function applyTimeTravelV2(
timetravelOffsetMs: number,
s: {
exchange?: ExchangeService;
merchant?: MerchantService;
walletClient?: WalletClient;
},
): Promise {
if (s.exchange) {
await s.exchange.stop();
s.exchange.setTimetravel(timetravelOffsetMs);
await s.exchange.start();
await s.exchange.pingUntilAvailable();
}
if (s.merchant) {
await s.merchant.stop();
s.merchant.setTimetravel(timetravelOffsetMs);
await s.merchant.start();
await s.merchant.pingUntilAvailable();
}
if (s.walletClient) {
await s.walletClient.call(WalletApiOperation.TestingSetTimetravel, {
offsetMs: timetravelOffsetMs,
});
}
}
/**
* Make a simple payment and check that it succeeded.
*/
export async function makeTestPaymentV2(
t: GlobalTestState,
args: {
merchant: MerchantServiceInterface;
walletClient: WalletClient;
order: Partial;
instance?: string;
},
auth: WithAuthorization = {},
): Promise {
// Set up order.
const { walletClient, merchant, instance } = args;
const merchantClient = new MerchantApiClient(
merchant.makeInstanceBaseUrl(instance),
);
const orderResp = await merchantClient.createOrder({
order: args.order,
});
let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
t.assertTrue(orderStatus.order_status === "unpaid");
// Make wallet pay for the order
const preparePayResult = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: orderStatus.taler_pay_uri,
},
);
t.assertTrue(
preparePayResult.status === PreparePayResultType.PaymentPossible,
);
const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
transactionId: preparePayResult.transactionId,
});
t.assertTrue(r2.type === ConfirmPayResultType.Done);
// Check if payment was successful.
orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
instance,
});
t.assertTrue(orderStatus.order_status === "paid");
}