/* 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, Amounts, AmountString, CheckPaymentResponse, codecForAny, codecForCheckPaymentResponse, ConfirmPayResultType, Duration, IntegrationTestArgs, IntegrationTestV2Args, j2s, Logger, NotificationType, PreparePayResultType, stringifyTalerUri, TalerCorebankApiClient, TalerUriAction, TestPayArgs, TestPayResult, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, URL, WithdrawTestBalanceRequest, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, readSuccessResponseJsonOrThrow, } from "@gnu-taler/taler-util/http"; import { OpenedPromise, openPromise } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { checkLogicInvariant } from "../util/invariants.js"; import { getBalances } from "./balance.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 { getPendingOperations } from "./pending.js"; import { getTransactionById, getTransactions } from "./transactions.js"; import { acceptWithdrawalFromUri } from "./withdraw.js"; const logger = new Logger("operations/testing.ts"); interface MerchantBackendInfo { baseUrl: string; authToken?: string; } export async function withdrawTestBalance( ws: InternalWalletState, 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, ); await acceptWithdrawalFromUri(ws, { talerWithdrawUri: wresp.taler_withdraw_uri, selectedExchange: exchangeBaseUrl, forcedDenomSel: req.forcedDenomSel, }); await corebankClient.confirmWithdrawalOperation(bankUser.username, { withdrawalOperationId: wresp.withdrawal_id, }); } /** * 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.postJson(reqUrl.href, 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.postJson(reqUrl, 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()); } async function makePayment( ws: InternalWalletState, merchant: MerchantBackendInfo, amount: string, summary: string, ): Promise<{ orderId: string }> { const orderResp = await createOrder( ws.http, merchant, amount, summary, "taler://fulfillment-success/thx", ); logger.trace("created order with orderId", orderResp.orderId); let paymentStatus = await checkPayment(ws.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(ws, talerPayUri); logger.trace("prepare pay result", preparePayResult); if (preparePayResult.status != "payment-possible") { throw Error("payment not possible"); } const confirmPayResult = await confirmPay( ws, preparePayResult.proposalId, undefined, ); logger.trace("confirmPayResult", confirmPayResult); paymentStatus = await checkPayment(ws.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, }; } export async function runIntegrationTest( ws: InternalWalletState, 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"); await withdrawTestBalance(ws, { amount: args.amountToWithdraw, corebankApiBaseUrl: args.corebankApiBaseUrl, exchangeBaseUrl: args.exchangeBaseUrl, }); await waitUntilTransactionsFinal(ws); logger.info("done withdrawing test balance"); const balance = await getBalances(ws); logger.trace(JSON.stringify(balance, null, 2)); const myMerchant: MerchantBackendInfo = { baseUrl: args.merchantBaseUrl, authToken: args.merchantAuthToken, }; await makePayment(ws, myMerchant, args.amountToSpend, "hello world"); // Wait until the refresh is done await waitUntilTransactionsFinal(ws); 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`); await withdrawTestBalance(ws, { amount: Amounts.stringify(withdrawAmountTwo), corebankApiBaseUrl: args.corebankApiBaseUrl, exchangeBaseUrl: args.exchangeBaseUrl, }); // Wait until the withdraw is done await waitUntilTransactionsFinal(ws); const { orderId: refundOrderId } = await makePayment( ws, myMerchant, Amounts.stringify(spendAmountTwo), "order that will be refunded", ); const refundUri = await refund( ws.http, myMerchant, refundOrderId, "test refund", Amounts.stringify(refundAmount), ); logger.trace("refund URI", refundUri); await startRefundQueryForUri(ws, refundUri); logger.trace("integration test: applied refund"); // Wait until the refund is done await waitUntilTransactionsFinal(ws); logger.trace("integration test: making payment after refund"); await makePayment( ws, myMerchant, Amounts.stringify(spendAmountThree), "payment after refund", ); logger.trace("integration test: make payment done"); await waitUntilTransactionsFinal(ws); logger.trace("integration test: all done!"); } /** * Wait until all transactions are in a final state. */ export async function waitUntilTransactionsFinal( ws: InternalWalletState, ): Promise { logger.info("waiting until all transactions are in a final state"); ws.ensureTaskLoopRunning(); let p: OpenedPromise | undefined = undefined; const cancelNotifs = 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(ws, { 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 pending work is processed. */ export async function waitUntilTasksProcessed( ws: InternalWalletState, ): Promise { logger.info("waiting until pending work is processed"); ws.ensureTaskLoopRunning(); let p: OpenedPromise | undefined = undefined; const cancelNotifs = ws.addNotificationListener((notif) => { if (!p) { return; } if (notif.type === NotificationType.PendingOperationProcessed) { p.resolve(); } }); while (1) { p = openPromise(); const pendingTasksResp = await getPendingOperations(ws); logger.info(`waiting on pending ops: ${j2s(pendingTasksResp)}`); let finished = true; for (const task of pendingTasksResp.pendingOperations) { if (task.isDue) { finished = false; } logger.info(`continuing waiting for task ${task.id}`); } if (finished) { break; } // Wait until task is done await p.promise; } logger.info("done waiting until pending work is processed"); cancelNotifs(); } export async function waitUntilRefreshesDone( ws: InternalWalletState, ): Promise { logger.info("waiting until all refresh transactions are in a final state"); ws.ensureTaskLoopRunning(); let p: OpenedPromise | undefined = undefined; const cancelNotifs = 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(ws, { 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( ws: InternalWalletState, transactionId: string, ): Promise { logger.info(`starting waiting for ${transactionId} to be in pending(ready)`); ws.ensureTaskLoopRunning(); let p: OpenedPromise | undefined = undefined; const cancelNotifs = ws.addNotificationListener((notif) => { if (!p) { return; } if (notif.type === NotificationType.TransactionStateTransition) { p.resolve(); } }); while (1) { p = openPromise(); const tx = await getTransactionById(ws, { 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( ws: InternalWalletState, transactionId: string, txState: TransactionState, ): Promise { logger.info( `starting waiting for ${transactionId} to be in ${JSON.stringify( txState, )})`, ); ws.ensureTaskLoopRunning(); let p: OpenedPromise | undefined = undefined; const cancelNotifs = ws.addNotificationListener((notif) => { if (!p) { return; } if (notif.type === NotificationType.TransactionStateTransition) { p.resolve(); } }); while (1) { p = openPromise(); const tx = await getTransactionById(ws, { 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 runIntegrationTest2( ws: InternalWalletState, args: IntegrationTestV2Args, ): Promise { // FIXME: Make sure that a task look is running, since we're // waiting for notifications. logger.info("running test with arguments", args); const exchangeInfo = await fetchFreshExchange(ws, args.exchangeBaseUrl); const currency = exchangeInfo.currency; const amountToWithdraw = Amounts.parseOrThrow(`${currency}:10`); const amountToSpend = Amounts.parseOrThrow(`${currency}:2`); logger.info("withdrawing test balance"); await withdrawTestBalance(ws, { amount: Amounts.stringify(amountToWithdraw), corebankApiBaseUrl: args.corebankApiBaseUrl, exchangeBaseUrl: args.exchangeBaseUrl, }); await waitUntilTransactionsFinal(ws); logger.info("done withdrawing test balance"); const balance = await getBalances(ws); logger.trace(JSON.stringify(balance, null, 2)); const myMerchant: MerchantBackendInfo = { baseUrl: args.merchantBaseUrl, authToken: args.merchantAuthToken, }; await makePayment( ws, myMerchant, Amounts.stringify(amountToSpend), "hello world", ); // Wait until the refresh is done await waitUntilTransactionsFinal(ws); 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`); await withdrawTestBalance(ws, { amount: Amounts.stringify(withdrawAmountTwo), corebankApiBaseUrl: args.corebankApiBaseUrl, exchangeBaseUrl: args.exchangeBaseUrl, }); // Wait until the withdraw is done await waitUntilTransactionsFinal(ws); const { orderId: refundOrderId } = await makePayment( ws, myMerchant, Amounts.stringify(spendAmountTwo), "order that will be refunded", ); const refundUri = await refund( ws.http, myMerchant, refundOrderId, "test refund", Amounts.stringify(refundAmount), ); logger.trace("refund URI", refundUri); await startRefundQueryForUri(ws, refundUri); logger.trace("integration test: applied refund"); // Wait until the refund is done await waitUntilTransactionsFinal(ws); logger.trace("integration test: making payment after refund"); await makePayment( ws, myMerchant, Amounts.stringify(spendAmountThree), "payment after refund", ); logger.trace("integration test: make payment done"); await waitUntilTransactionsFinal(ws); const peerPushInit = await initiatePeerPushDebit(ws, { 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(ws, peerPushInit.transactionId); const talerUri = stringifyTalerUri({ type: TalerUriAction.PayPush, exchangeBaseUrl: peerPushInit.exchangeBaseUrl, contractPriv: peerPushInit.contractPriv, }); const txDetails = await getTransactionById(ws, { 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(ws, { talerUri: txDetails.talerUri, }); await confirmPeerPushCredit(ws, { transactionId: peerPushCredit.transactionId, }); const peerPullInit = await initiatePeerPullPayment(ws, { 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(ws, peerPullInit.transactionId); const peerPullInc = await preparePeerPullDebit(ws, { talerUri: peerPullInit.talerUri, }); await confirmPeerPullDebit(ws, { peerPullDebitId: peerPullInc.peerPullDebitId, }); await waitUntilTransactionsFinal(ws); logger.trace("integration test: all done!"); } export async function testPay( ws: InternalWalletState, args: TestPayArgs, ): Promise { logger.trace("creating order"); const merchant = { authToken: args.merchantAuthToken, baseUrl: args.merchantBaseUrl, }; const orderResp = await createOrder( ws.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(ws.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(ws, talerPayUri); if (result.status !== PreparePayResultType.PaymentPossible) { throw Error(`unexpected prepare pay status: ${result.status}`); } const r = await confirmPay( ws, result.proposalId, undefined, args.forcedCoinSel, ); if (r.type != ConfirmPayResultType.Done) { throw Error("payment not done"); } const purchase = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.get(result.proposalId); }); checkLogicInvariant(!!purchase); return { payCoinSelection: purchase.payInfo?.payCoinSelection!, }; }