/* 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, 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 { genericWaitForState } from "./common.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"); await wex.taskScheduler.ensureRunning(); await genericWaitForState(wex, { filterNotification(notif) { if (notif.type !== NotificationType.TransactionStateTransition) { return false; } switch (notif.newTxState.major) { case TransactionMajorState.Pending: case TransactionMajorState.Aborting: return false; default: return true; } }, async checkState() { const txs = await getTransactions(wex, { includeRefreshes: true, filterByState: "nonfinal", }); for (const tx of txs.transactions) { switch (tx.txState.major) { case TransactionMajorState.Pending: case TransactionMajorState.Aborting: case TransactionMajorState.Suspended: case TransactionMajorState.SuspendedAborting: logger.info( `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, ); return false; } } return true; }, }); logger.info("done waiting until all transactions are in a final state"); } export async function waitTasksDone( wex: WalletExecutionContext, ): Promise { await genericWaitForState(wex, { async checkState() { return wex.taskScheduler.isIdle(); }, filterNotification(notif) { return notif.type === NotificationType.Idle; }, }); } /** * 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; } const txIdSet = new Set(transactionIds); await genericWaitForState(wex, { filterNotification(notif) { if (notif.type !== NotificationType.TransactionStateTransition) { return false; } if (!txIdSet.has(notif.transactionId)) { return false; } switch (notif.newTxState.major) { case TransactionMajorState.Pending: case TransactionMajorState.Aborting: case TransactionMajorState.Suspended: case TransactionMajorState.SuspendedAborting: return false; } return true; }, async checkState() { const txs = await getTransactions(wex, { includeRefreshes: true, filterByState: "nonfinal", }); 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: logger.info( `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, ); return false; } } // No transaction is pending, we're done waiting! return true; }, }); 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"); await genericWaitForState(wex, { filterNotification(notif) { if (notif.type !== NotificationType.TransactionStateTransition) { return false; } switch (notif.newTxState.major) { case TransactionMajorState.Pending: case TransactionMajorState.Aborting: return false; default: return true; } }, async checkState() { const txs = await getTransactions(wex, { includeRefreshes: true, filterByState: "nonfinal", }); 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: logger.info( `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, ); return false; } } return true; }, }); logger.info("done waiting until all refreshes are in a final state"); } async function waitUntilTransactionPendingReady( wex: WalletExecutionContext, transactionId: string, ): Promise { return await waitTransactionState(wex, transactionId, { major: TransactionMajorState.Pending, minor: TransactionMinorState.Ready, }); } /** * 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, )})`, ); await genericWaitForState(wex, { async checkState() { const tx = await getTransactionById(wex, { transactionId, }); return ( tx.txState.major === txState.major && tx.txState.minor === txState.minor ); }, filterNotification(notif) { return notif.type === NotificationType.TransactionStateTransition; }, }); logger.info( `done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`, ); } 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 { await 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( { storeNames: ["purchases"] }, async (tx) => { return tx.purchases.get(result.proposalId); }, ); checkLogicInvariant(!!purchase); return { numCoins: purchase.payInfo?.payCoinSelection?.coinContributions.length ?? 0, }; }