/*
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
*/
/**
* @file
* Implementation of wallet-core operations that are used for testing,
* but typically not in the production wallet.
*/
/**
* Imports.
*/
import {
AbsoluteTime,
addPaytoQueryParams,
Amounts,
AmountString,
checkLogicInvariant,
CheckPaymentResponse,
codecForAny,
codecForCheckPaymentResponse,
ConfirmPayResultType,
Duration,
IntegrationTestArgs,
IntegrationTestV2Args,
j2s,
Logger,
NotificationType,
OpenedPromise,
openPromise,
parsePaytoUri,
PreparePayResultType,
TalerCorebankApiClient,
TestPayArgs,
TestPayResult,
TransactionMajorState,
TransactionMinorState,
TransactionState,
TransactionType,
URL,
WithdrawTestBalanceRequest,
} from "@gnu-taler/taler-util";
import {
HttpRequestLibrary,
readSuccessResponseJsonOrThrow,
} from "@gnu-taler/taler-util/http";
import { getBalances } from "./balance.js";
import { createDepositGroup } from "./deposits.js";
import { fetchFreshExchange } from "./exchanges.js";
import {
confirmPay,
preparePayForUri,
startRefundQueryForUri,
} from "./pay-merchant.js";
import { initiatePeerPullPayment } from "./pay-peer-pull-credit.js";
import {
confirmPeerPullDebit,
preparePeerPullDebit,
} from "./pay-peer-pull-debit.js";
import {
confirmPeerPushCredit,
preparePeerPushCredit,
} from "./pay-peer-push-credit.js";
import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
import { getRefreshesForTransaction } from "./refresh.js";
import { getTransactionById, getTransactions } from "./transactions.js";
import type { WalletExecutionContext } from "./wallet.js";
import { acceptWithdrawalFromUri } from "./withdraw.js";
const logger = new Logger("operations/testing.ts");
interface MerchantBackendInfo {
baseUrl: string;
authToken?: string;
}
export interface WithdrawTestBalanceResult {
/**
* Transaction ID of the newly created withdrawal transaction.
*/
transactionId: string;
/**
* Account of the user registered for the withdrawal.
*/
accountPaytoUri: string;
}
export async function withdrawTestBalance(
wex: WalletExecutionContext,
req: WithdrawTestBalanceRequest,
): Promise {
const amount = req.amount;
const exchangeBaseUrl = req.exchangeBaseUrl;
const corebankApiBaseUrl = req.corebankApiBaseUrl;
logger.trace(
`Registering bank user, bank access base url ${corebankApiBaseUrl}`,
);
const corebankClient = new TalerCorebankApiClient(corebankApiBaseUrl);
const bankUser = await corebankClient.createRandomBankUser();
logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`);
corebankClient.setAuth(bankUser);
const wresp = await corebankClient.createWithdrawalOperation(
bankUser.username,
amount,
);
const acceptResp = await acceptWithdrawalFromUri(wex, {
talerWithdrawUri: wresp.taler_withdraw_uri,
selectedExchange: exchangeBaseUrl,
forcedDenomSel: req.forcedDenomSel,
});
await corebankClient.confirmWithdrawalOperation(bankUser.username, {
withdrawalOperationId: wresp.withdrawal_id,
});
return {
transactionId: acceptResp.transactionId,
accountPaytoUri: bankUser.accountPaytoUri,
};
}
/**
* FIXME: User MerchantApiClient instead.
*/
function getMerchantAuthHeader(m: MerchantBackendInfo): Record {
if (m.authToken) {
return {
Authorization: `Bearer ${m.authToken}`,
};
}
return {};
}
/**
* FIXME: User MerchantApiClient instead.
*/
async function refund(
http: HttpRequestLibrary,
merchantBackend: MerchantBackendInfo,
orderId: string,
reason: string,
refundAmount: string,
): Promise {
const reqUrl = new URL(
`private/orders/${orderId}/refund`,
merchantBackend.baseUrl,
);
const refundReq = {
order_id: orderId,
reason,
refund: refundAmount,
};
const resp = await http.fetch(reqUrl.href, {
method: "POST",
body: refundReq,
headers: getMerchantAuthHeader(merchantBackend),
});
const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
const refundUri = r.taler_refund_uri;
if (!refundUri) {
throw Error("no refund URI in response");
}
return refundUri;
}
/**
* FIXME: User MerchantApiClient instead.
*/
async function createOrder(
http: HttpRequestLibrary,
merchantBackend: MerchantBackendInfo,
amount: string,
summary: string,
fulfillmentUrl: string,
): Promise<{ orderId: string }> {
const t = Math.floor(new Date().getTime() / 1000) + 15 * 60;
const reqUrl = new URL("private/orders", merchantBackend.baseUrl).href;
const orderReq = {
order: {
amount,
summary,
fulfillment_url: fulfillmentUrl,
refund_deadline: { t_s: t },
wire_transfer_deadline: { t_s: t },
},
};
const resp = await http.fetch(reqUrl, {
method: "POST",
body: orderReq,
headers: getMerchantAuthHeader(merchantBackend),
});
const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
const orderId = r.order_id;
if (!orderId) {
throw Error("no order id in response");
}
return { orderId };
}
/**
* FIXME: User MerchantApiClient instead.
*/
async function checkPayment(
http: HttpRequestLibrary,
merchantBackend: MerchantBackendInfo,
orderId: string,
): Promise {
const reqUrl = new URL(`private/orders/${orderId}`, merchantBackend.baseUrl);
reqUrl.searchParams.set("order_id", orderId);
const resp = await http.fetch(reqUrl.href, {
headers: getMerchantAuthHeader(merchantBackend),
});
return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse());
}
interface MakePaymentResult {
orderId: string;
paymentTransactionId: string;
}
async function makePayment(
wex: WalletExecutionContext,
merchant: MerchantBackendInfo,
amount: string,
summary: string,
): Promise {
const orderResp = await createOrder(
wex.http,
merchant,
amount,
summary,
"taler://fulfillment-success/thx",
);
logger.trace("created order with orderId", orderResp.orderId);
let paymentStatus = await checkPayment(wex.http, merchant, orderResp.orderId);
logger.trace("payment status", paymentStatus);
const talerPayUri = paymentStatus.taler_pay_uri;
if (!talerPayUri) {
throw Error("no taler://pay/ URI in payment response");
}
const preparePayResult = await preparePayForUri(wex, talerPayUri);
logger.trace("prepare pay result", preparePayResult);
if (preparePayResult.status != "payment-possible") {
throw Error("payment not possible");
}
const confirmPayResult = await confirmPay(
wex,
preparePayResult.transactionId,
undefined,
);
logger.trace("confirmPayResult", confirmPayResult);
paymentStatus = await checkPayment(wex.http, merchant, orderResp.orderId);
logger.trace("payment status after wallet payment:", paymentStatus);
if (paymentStatus.order_status !== "paid") {
throw Error("payment did not succeed");
}
return {
orderId: orderResp.orderId,
paymentTransactionId: preparePayResult.transactionId,
};
}
export async function runIntegrationTest(
wex: WalletExecutionContext,
args: IntegrationTestArgs,
): Promise {
logger.info("running test with arguments", args);
const parsedSpendAmount = Amounts.parseOrThrow(args.amountToSpend);
const currency = parsedSpendAmount.currency;
logger.info("withdrawing test balance");
const withdrawRes1 = await withdrawTestBalance(wex, {
amount: args.amountToWithdraw,
corebankApiBaseUrl: args.corebankApiBaseUrl,
exchangeBaseUrl: args.exchangeBaseUrl,
});
await waitUntilGivenTransactionsFinal(wex, [withdrawRes1.transactionId]);
logger.info("done withdrawing test balance");
const balance = await getBalances(wex);
logger.trace(JSON.stringify(balance, null, 2));
const myMerchant: MerchantBackendInfo = {
baseUrl: args.merchantBaseUrl,
authToken: args.merchantAuthToken,
};
const makePaymentRes = await makePayment(
wex,
myMerchant,
args.amountToSpend,
"hello world",
);
await waitUntilTransactionWithAssociatedRefreshesFinal(
wex,
makePaymentRes.paymentTransactionId,
);
logger.trace("withdrawing test balance for refund");
const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
const withdrawRes2 = await withdrawTestBalance(wex, {
amount: Amounts.stringify(withdrawAmountTwo),
corebankApiBaseUrl: args.corebankApiBaseUrl,
exchangeBaseUrl: args.exchangeBaseUrl,
});
await waitUntilGivenTransactionsFinal(wex, [withdrawRes2.transactionId]);
const { orderId: refundOrderId } = await makePayment(
wex,
myMerchant,
Amounts.stringify(spendAmountTwo),
"order that will be refunded",
);
const refundUri = await refund(
wex.http,
myMerchant,
refundOrderId,
"test refund",
Amounts.stringify(refundAmount),
);
logger.trace("refund URI", refundUri);
const refundResp = await startRefundQueryForUri(wex, refundUri);
logger.trace("integration test: applied refund");
// Wait until the refund is done
await waitUntilTransactionWithAssociatedRefreshesFinal(
wex,
refundResp.transactionId,
);
logger.trace("integration test: making payment after refund");
const paymentResp2 = await makePayment(
wex,
myMerchant,
Amounts.stringify(spendAmountThree),
"payment after refund",
);
logger.trace("integration test: make payment done");
await waitUntilGivenTransactionsFinal(wex, [
paymentResp2.paymentTransactionId,
]);
await waitUntilGivenTransactionsFinal(
wex,
await getRefreshesForTransaction(wex, paymentResp2.paymentTransactionId),
);
logger.trace("integration test: all done!");
}
/**
* Wait until all transactions are in a final state.
*/
export async function waitUntilAllTransactionsFinal(
wex: WalletExecutionContext,
): Promise {
logger.info("waiting until all transactions are in a final state");
wex.taskScheduler.ensureRunning();
let p: OpenedPromise | undefined = undefined;
const cancelNotifs = wex.ws.addNotificationListener((notif) => {
if (!p) {
return;
}
if (notif.type === NotificationType.TransactionStateTransition) {
switch (notif.newTxState.major) {
case TransactionMajorState.Pending:
case TransactionMajorState.Aborting:
break;
default:
p.resolve();
}
}
});
while (1) {
p = openPromise();
const txs = await getTransactions(wex, {
includeRefreshes: true,
filterByState: "nonfinal",
});
let finished = true;
for (const tx of txs.transactions) {
switch (tx.txState.major) {
case TransactionMajorState.Pending:
case TransactionMajorState.Aborting:
case TransactionMajorState.Suspended:
case TransactionMajorState.SuspendedAborting:
finished = false;
logger.info(
`continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
);
break;
}
}
if (finished) {
break;
}
// Wait until transaction state changed
await p.promise;
}
cancelNotifs();
logger.info("done waiting until all transactions are in a final state");
}
/**
* Wait until all chosen transactions are in a final state.
*/
export async function waitUntilGivenTransactionsFinal(
wex: WalletExecutionContext,
transactionIds: string[],
): Promise {
logger.info(
`waiting until given ${transactionIds.length} transactions are in a final state`,
);
logger.info(`transaction IDs are: ${j2s(transactionIds)}`);
if (transactionIds.length === 0) {
return;
}
wex.taskScheduler.ensureRunning();
const txIdSet = new Set(transactionIds);
let p: OpenedPromise | undefined = undefined;
const cancelNotifs = wex.ws.addNotificationListener((notif) => {
if (!p) {
return;
}
if (notif.type === NotificationType.TransactionStateTransition) {
if (!txIdSet.has(notif.transactionId)) {
return;
}
switch (notif.newTxState.major) {
case TransactionMajorState.Pending:
case TransactionMajorState.Aborting:
case TransactionMajorState.Suspended:
case TransactionMajorState.SuspendedAborting:
break;
default:
p.resolve();
}
}
});
while (1) {
p = openPromise();
const txs = await getTransactions(wex, {
includeRefreshes: true,
filterByState: "nonfinal",
});
let finished = true;
for (const tx of txs.transactions) {
if (!txIdSet.has(tx.transactionId)) {
// Don't look at this transaction, we're not interested in it.
continue;
}
switch (tx.txState.major) {
case TransactionMajorState.Pending:
case TransactionMajorState.Aborting:
case TransactionMajorState.Suspended:
case TransactionMajorState.SuspendedAborting:
finished = false;
logger.info(
`continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
);
break;
}
}
if (finished) {
break;
}
// Wait until transaction state changed
await p.promise;
}
cancelNotifs();
logger.info("done waiting until given transactions are in a final state");
}
export async function waitUntilRefreshesDone(
wex: WalletExecutionContext,
): Promise {
logger.info("waiting until all refresh transactions are in a final state");
wex.taskScheduler.ensureRunning();
let p: OpenedPromise | undefined = undefined;
const cancelNotifs = wex.ws.addNotificationListener((notif) => {
if (!p) {
return;
}
if (notif.type === NotificationType.TransactionStateTransition) {
switch (notif.newTxState.major) {
case TransactionMajorState.Pending:
case TransactionMajorState.Aborting:
break;
default:
p.resolve();
}
}
});
while (1) {
p = openPromise();
const txs = await getTransactions(wex, {
includeRefreshes: true,
filterByState: "nonfinal",
});
let finished = true;
for (const tx of txs.transactions) {
if (tx.type !== TransactionType.Refresh) {
continue;
}
switch (tx.txState.major) {
case TransactionMajorState.Pending:
case TransactionMajorState.Aborting:
case TransactionMajorState.Suspended:
case TransactionMajorState.SuspendedAborting:
finished = false;
logger.info(
`continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
);
break;
}
}
if (finished) {
break;
}
// Wait until transaction state changed
await p.promise;
}
cancelNotifs();
logger.info("done waiting until all refreshes are in a final state");
}
async function waitUntilTransactionPendingReady(
wex: WalletExecutionContext,
transactionId: string,
): Promise {
logger.info(`starting waiting for ${transactionId} to be in pending(ready)`);
wex.taskScheduler.ensureRunning();
let p: OpenedPromise | undefined = undefined;
const cancelNotifs = wex.ws.addNotificationListener((notif) => {
if (!p) {
return;
}
if (notif.type === NotificationType.TransactionStateTransition) {
p.resolve();
}
});
while (1) {
p = openPromise();
const tx = await getTransactionById(wex, {
transactionId,
});
if (
tx.txState.major == TransactionMajorState.Pending &&
tx.txState.minor === TransactionMinorState.Ready
) {
break;
}
// Wait until transaction state changed
await p.promise;
}
logger.info(`done waiting for ${transactionId} to be in pending(ready)`);
cancelNotifs();
}
/**
* Wait until a transaction is in a particular state.
*/
export async function waitTransactionState(
wex: WalletExecutionContext,
transactionId: string,
txState: TransactionState,
): Promise {
logger.info(
`starting waiting for ${transactionId} to be in ${JSON.stringify(
txState,
)})`,
);
wex.taskScheduler.ensureRunning();
let p: OpenedPromise | undefined = undefined;
const cancelNotifs = wex.ws.addNotificationListener((notif) => {
if (!p) {
return;
}
if (notif.type === NotificationType.TransactionStateTransition) {
p.resolve();
}
});
while (1) {
p = openPromise();
const tx = await getTransactionById(wex, {
transactionId,
});
if (
tx.txState.major === txState.major &&
tx.txState.minor === txState.minor
) {
break;
}
// Wait until transaction state changed
await p.promise;
}
logger.info(
`done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`,
);
cancelNotifs();
}
export async function waitUntilTransactionWithAssociatedRefreshesFinal(
wex: WalletExecutionContext,
transactionId: string,
): Promise {
await waitUntilGivenTransactionsFinal(wex, [transactionId]);
await waitUntilGivenTransactionsFinal(
wex,
await getRefreshesForTransaction(wex, transactionId),
);
}
export async function waitUntilTransactionFinal(
wex: WalletExecutionContext,
transactionId: string,
): Promise {
await waitUntilGivenTransactionsFinal(wex, [transactionId]);
}
export async function runIntegrationTest2(
wex: WalletExecutionContext,
args: IntegrationTestV2Args,
): Promise {
wex.taskScheduler.ensureRunning();
logger.info("running test with arguments", args);
const exchangeInfo = await fetchFreshExchange(wex, args.exchangeBaseUrl);
const currency = exchangeInfo.currency;
const amountToWithdraw = Amounts.parseOrThrow(`${currency}:10`);
const amountToSpend = Amounts.parseOrThrow(`${currency}:2`);
logger.info("withdrawing test balance");
const withdrawalRes = await withdrawTestBalance(wex, {
amount: Amounts.stringify(amountToWithdraw),
corebankApiBaseUrl: args.corebankApiBaseUrl,
exchangeBaseUrl: args.exchangeBaseUrl,
});
await waitUntilTransactionFinal(wex, withdrawalRes.transactionId);
logger.info("done withdrawing test balance");
const balance = await getBalances(wex);
logger.trace(JSON.stringify(balance, null, 2));
const myMerchant: MerchantBackendInfo = {
baseUrl: args.merchantBaseUrl,
authToken: args.merchantAuthToken,
};
const makePaymentRes = await makePayment(
wex,
myMerchant,
Amounts.stringify(amountToSpend),
"hello world",
);
await waitUntilTransactionWithAssociatedRefreshesFinal(
wex,
makePaymentRes.paymentTransactionId,
);
logger.trace("withdrawing test balance for refund");
const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
const withdrawalRes2 = await withdrawTestBalance(wex, {
amount: Amounts.stringify(withdrawAmountTwo),
corebankApiBaseUrl: args.corebankApiBaseUrl,
exchangeBaseUrl: args.exchangeBaseUrl,
});
// Wait until the withdraw is done
await waitUntilTransactionFinal(wex, withdrawalRes2.transactionId);
const { orderId: refundOrderId } = await makePayment(
wex,
myMerchant,
Amounts.stringify(spendAmountTwo),
"order that will be refunded",
);
const refundUri = await refund(
wex.http,
myMerchant,
refundOrderId,
"test refund",
Amounts.stringify(refundAmount),
);
logger.trace("refund URI", refundUri);
const refundResp = await startRefundQueryForUri(wex, refundUri);
logger.trace("integration test: applied refund");
// Wait until the refund is done
await waitUntilTransactionWithAssociatedRefreshesFinal(
wex,
refundResp.transactionId,
);
logger.trace("integration test: making payment after refund");
const makePaymentRes2 = await makePayment(
wex,
myMerchant,
Amounts.stringify(spendAmountThree),
"payment after refund",
);
await waitUntilTransactionWithAssociatedRefreshesFinal(
wex,
makePaymentRes2.paymentTransactionId,
);
logger.trace("integration test: make payment done");
const peerPushInit = await initiatePeerPushDebit(wex, {
partialContractTerms: {
amount: `${currency}:1` as AmountString,
summary: "Payment Peer Push Test",
purse_expiration: AbsoluteTime.toProtocolTimestamp(
AbsoluteTime.addDuration(
AbsoluteTime.now(),
Duration.fromSpec({ hours: 1 }),
),
),
},
});
await waitUntilTransactionPendingReady(wex, peerPushInit.transactionId);
const txDetails = await getTransactionById(wex, {
transactionId: peerPushInit.transactionId,
});
if (txDetails.type !== TransactionType.PeerPushDebit) {
throw Error("internal invariant failed");
}
if (!txDetails.talerUri) {
throw Error("internal invariant failed");
}
const peerPushCredit = await preparePeerPushCredit(wex, {
talerUri: txDetails.talerUri,
});
await confirmPeerPushCredit(wex, {
transactionId: peerPushCredit.transactionId,
});
const peerPullInit = await initiatePeerPullPayment(wex, {
partialContractTerms: {
amount: `${currency}:1` as AmountString,
summary: "Payment Peer Pull Test",
purse_expiration: AbsoluteTime.toProtocolTimestamp(
AbsoluteTime.addDuration(
AbsoluteTime.now(),
Duration.fromSpec({ hours: 1 }),
),
),
},
});
await waitUntilTransactionPendingReady(wex, peerPullInit.transactionId);
const peerPullInc = await preparePeerPullDebit(wex, {
talerUri: peerPullInit.talerUri,
});
await confirmPeerPullDebit(wex, {
transactionId: peerPullInc.transactionId,
});
await waitUntilTransactionWithAssociatedRefreshesFinal(
wex,
peerPullInc.transactionId,
);
await waitUntilTransactionWithAssociatedRefreshesFinal(
wex,
peerPullInit.transactionId,
);
await waitUntilTransactionWithAssociatedRefreshesFinal(
wex,
peerPushCredit.transactionId,
);
await waitUntilTransactionWithAssociatedRefreshesFinal(
wex,
peerPushInit.transactionId,
);
let depositPayto = withdrawalRes.accountPaytoUri;
const parsedPayto = parsePaytoUri(depositPayto);
if (!parsedPayto) {
throw Error("invalid payto");
}
// Work around libeufin-bank bug where receiver-name is missing
if (!parsedPayto.params["receiver-name"]) {
depositPayto = addPaytoQueryParams(depositPayto, {
"receiver-name": "Test",
});
}
await createDepositGroup(wex, {
amount: `${currency}:5` as AmountString,
depositPaytoUri: depositPayto,
});
logger.trace("integration test: all done!");
}
export async function testPay(
wex: WalletExecutionContext,
args: TestPayArgs,
): Promise {
logger.trace("creating order");
const merchant = {
authToken: args.merchantAuthToken,
baseUrl: args.merchantBaseUrl,
};
const orderResp = await createOrder(
wex.http,
merchant,
args.amount,
args.summary,
"taler://fulfillment-success/thank+you",
);
logger.trace("created new order with order ID", orderResp.orderId);
const checkPayResp = await checkPayment(
wex.http,
merchant,
orderResp.orderId,
);
const talerPayUri = checkPayResp.taler_pay_uri;
if (!talerPayUri) {
console.error("fatal: no taler pay URI received from backend");
process.exit(1);
}
logger.trace("taler pay URI:", talerPayUri);
const result = await preparePayForUri(wex, talerPayUri);
if (result.status !== PreparePayResultType.PaymentPossible) {
throw Error(`unexpected prepare pay status: ${result.status}`);
}
const r = await confirmPay(
wex,
result.transactionId,
undefined,
args.forcedCoinSel,
);
if (r.type != ConfirmPayResultType.Done) {
throw Error("payment not done");
}
const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
return tx.purchases.get(result.proposalId);
});
checkLogicInvariant(!!purchase);
return {
numCoins: purchase.payInfo?.payCoinSelection.coinContributions.length ?? 0,
};
}