taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit beb6e25bf87b4e159497a13e3feae5c69ab59871
parent e6b44344a99e9d985a9587c11d71a59bcb8187d3
Author: Florian Dold <florian@dold.me>
Date:   Fri, 11 Jul 2025 00:37:46 +0200

harness,wallet: make tests pass under libeufin

In most cases, we simply didn't create accounts required for the test.
The taler-fakebank doesn't require this step and makes up accounts on
the spot.

But in withdrawal-bank-integrated, the bug was an idempotency issue in
wallet-core that only showed up when run under libeufin (likely timing
sensitive).

Diffstat:
Mpackages/taler-harness/src/harness/harness.ts | 31+++++++++++++++++++++++--------
Mpackages/taler-harness/src/harness/tops.ts | 28++++++++++++++++++++++++----
Mpackages/taler-harness/src/integrationtests/test-exchange-kyc-auth.ts | 8++++++++
Mpackages/taler-harness/src/integrationtests/test-kyc-decisions.ts | 8++++++++
Mpackages/taler-harness/src/integrationtests/test-kyc-merchant-aggregate.ts | 5+++--
Mpackages/taler-harness/src/integrationtests/test-peer-push-large.ts | 6+++++-
Mpackages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts | 72+++++++++++++++++++++++++++++-------------------------------------------
Mpackages/taler-wallet-core/src/withdraw.ts | 138++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
8 files changed, 177 insertions(+), 119 deletions(-)

diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -783,6 +783,15 @@ export class FakebankService return new FakebankService(gc, bc, cfgFilename); } + changeConfig(f: (config: Configuration) => void) { + const config = Configuration.load( + this.configFile, + ConfigSources["taler-exchange"], + ); + f(config); + config.writeTo(this.configFile, { excludeDefaults: true }); + } + setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) { if (!!this.proc) { throw Error("Can't set suggested exchange while bank is running."); @@ -922,12 +931,7 @@ export class LibeufinBankService config.setString( "libeufin-bank", "default_debt_limit", - bc.maxDebt ?? `${bc.currency}:100`, - ); - config.setString( - "libeufin-bank", - "DEFAULT_DEBT_LIMIT", - `${bc.currency}:100`, + bc.maxDebt ?? `${bc.currency}:999999`, ); config.setString( "libeufin-bank", @@ -971,6 +975,15 @@ export class LibeufinBankService return new FakebankService(gc, bc, cfgFilename); } + changeConfig(f: (config: Configuration) => void) { + const config = Configuration.load( + this.configFile, + ConfigSources["libeufin-bank"], + ); + f(config); + config.writeTo(this.configFile, { excludeDefaults: true }); + } + setSuggestedExchange(e: ExchangeServiceInterface) { if (!!this.proc) { throw Error("Can't set suggested exchange while bank is running."); @@ -1074,6 +1087,8 @@ export interface BankServiceHandle { pingUntilAvailable(): Promise<void>; stop(): Promise<void>; getAdminAuth(): { username: string; password: string }; + + changeConfig(f: (config: Configuration) => void): void; } export type BankService = BankServiceHandle; @@ -2670,7 +2685,7 @@ export async function doMerchantKycAuth( t: GlobalTestState, req: { exchangeBankAccount: HarnessExchangeBankAccount; - wireGatewayAuth: TalerWireGatewayAuth; + bankAdminAuth: { username: string; password: string }; merchant: MerchantServiceInterface; bankClient: TalerCorebankApiClient; }, @@ -2727,7 +2742,7 @@ export async function doMerchantKycAuth( }); succeedOrThrow( await wireGatewayApiClient.addKycAuth({ - auth: req.wireGatewayAuth, + auth: req.bankAdminAuth, body: { amount: "TESTKUDOS:0.1", debit_account: kycRespOne.kyc_data[0].payto_uri, diff --git a/packages/taler-harness/src/harness/tops.ts b/packages/taler-harness/src/harness/tops.ts @@ -24,6 +24,7 @@ import { decodeCrock, Duration, encodeCrock, + getRandomBytes, hashNormalizedPaytoUri, j2s, KycRule, @@ -688,20 +689,39 @@ export async function createTopsEnvironment( await merchant.start(); + const merchantAdminPayto = getTestHarnessPaytoForLabel("merchant-default"); + await merchant.addInstanceWithWireAccount({ id: "admin", name: "Default Instance", - paytoUris: [getTestHarnessPaytoForLabel("merchant-default")], + paytoUris: [merchantAdminPayto], defaultWireTransferDelay: TalerProtocolDuration.fromSpec({ minutes: 1 }), }); + await bankClient.registerAccountExtended({ + name: "merchant-default", + password: encodeCrock(getRandomBytes(32)), + username: "merchant-default", + payto_uri: merchantAdminPayto, + }); + + const merchantInstId = "minst1"; + const merchantInstPaytoUri = getTestHarnessPaytoForLabel(merchantInstId); + await merchant.addInstanceWithWireAccount({ - id: "minst1", - name: "minst1", - paytoUris: [getTestHarnessPaytoForLabel("minst1")], + id: merchantInstId, + name: merchantInstId, + paytoUris: [merchantInstPaytoUri], defaultWireTransferDelay: TalerProtocolDuration.fromSpec({ minutes: 1 }), }); + await bankClient.registerAccountExtended({ + name: merchantInstId, + password: encodeCrock(getRandomBytes(32)), + username: merchantInstId, + payto_uri: merchantInstPaytoUri, + }); + const exchangeBankAccount: HarnessExchangeBankAccount = { wireGatewayAuth: { username: exchangeBankUsername, diff --git a/packages/taler-harness/src/integrationtests/test-exchange-kyc-auth.ts b/packages/taler-harness/src/integrationtests/test-exchange-kyc-auth.ts @@ -21,6 +21,7 @@ import { Configuration, createEddsaKeyPair, encodeCrock, + getRandomBytes, hashNormalizedPaytoUri, HttpStatusCode, j2s, @@ -111,6 +112,13 @@ export async function runExchangeKycAuthTest(t: GlobalTestState) { const merchantPayto = getTestHarnessPaytoForLabel("merchant-default"); + await bankClient.registerAccountExtended({ + name: "merchant-default", + password: encodeCrock(getRandomBytes(32)), + username: "merchant-default", + payto_uri: merchantPayto, + }); + const cryptoApi = createSyncCryptoApi(); const wireGatewayApiClient = new TalerWireGatewayHttpClient( diff --git a/packages/taler-harness/src/integrationtests/test-kyc-decisions.ts b/packages/taler-harness/src/integrationtests/test-kyc-decisions.ts @@ -24,6 +24,7 @@ import { Configuration, Duration, encodeCrock, + getRandomBytes, hashNormalizedPaytoUri, j2s, LimitOperationType, @@ -84,6 +85,13 @@ export async function runKycDecisionsTest(t: GlobalTestState) { const merchantPayto = getTestHarnessPaytoForLabel("merchant-default"); + await bankClient.registerAccountExtended({ + name: "merchant-default", + password: encodeCrock(getRandomBytes(32)), + username: "merchant-default", + payto_uri: merchantPayto, + }); + const cryptoApi = createSyncCryptoApi(); const wireGatewayApiClient = new TalerWireGatewayHttpClient( diff --git a/packages/taler-harness/src/integrationtests/test-kyc-merchant-aggregate.ts b/packages/taler-harness/src/integrationtests/test-kyc-merchant-aggregate.ts @@ -58,7 +58,7 @@ export async function runKycMerchantAggregateTest(t: GlobalTestState) { walletClient, bankClient, exchange, - amlKeypair, + bank, exchangeBankAccount, } = await createKycTestkudosEnvironment(t, { adjustExchangeConfig }); @@ -77,9 +77,10 @@ export async function runKycMerchantAggregateTest(t: GlobalTestState) { bankClient, exchangeBankAccount, merchant, - wireGatewayAuth: exchangeBankAccount.wireGatewayAuth, + bankAdminAuth: bank.getAdminAuth(), }); + await makeTestPaymentV2(t, { merchant, walletClient, diff --git a/packages/taler-harness/src/integrationtests/test-peer-push-large.ts b/packages/taler-harness/src/integrationtests/test-peer-push-large.ts @@ -69,7 +69,11 @@ export async function runPeerPushLargeTest(t: GlobalTestState) { { walletClient: wallet1, bankClient, exchange }, { walletClient: wallet2 }, ] = await Promise.all([ - createSimpleTestkudosEnvironmentV3(t, coinConfigList), + createSimpleTestkudosEnvironmentV3(t, coinConfigList, { + additionalBankConfig(b) { + + }, + }), createWalletDaemonWithClient(t, { name: "w2" }) diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts @@ -27,7 +27,10 @@ import { j2s, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { createSimpleTestkudosEnvironmentV3, createWalletDaemonWithClient } from "../harness/environments.js"; +import { + createSimpleTestkudosEnvironmentV3, + createWalletDaemonWithClient, +} from "../harness/environments.js"; import { GlobalTestState } from "../harness/harness.js"; /** @@ -42,7 +45,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { createSimpleTestkudosEnvironmentV3(t), createWalletDaemonWithClient(t, { name: "w2", - }) + }), ]); const user = await bankClient.createRandomBankUser(); bankClient.setAuth(user); @@ -55,12 +58,9 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { t.logStep("Hand it to the wallet"); - await wallet1.call( - WalletApiOperation.GetWithdrawalDetailsForUri, - { - talerWithdrawUri: withdrawal.taler_withdraw_uri, - }, - ); + await wallet1.call(WalletApiOperation.GetWithdrawalDetailsForUri, { + talerWithdrawUri: withdrawal.taler_withdraw_uri, + }); t.logStep("Withdraw"); @@ -184,48 +184,34 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { t.logStep("Check rescan idempotent"); { - await wallet1.call( - WalletApiOperation.GetWithdrawalDetailsForUri, - { - talerWithdrawUri: withdrawal.taler_withdraw_uri, - }, - ); - // FIXME #9683 wallet should already know withdrawal already completed by this wallet - - const e = await t.assertThrowsTalerErrorAsyncLegacy( - wallet1.call( - WalletApiOperation.AcceptBankIntegratedWithdrawal, - { - exchangeBaseUrl: exchange.baseUrl, - talerWithdrawUri: withdrawal.taler_withdraw_uri, - }, - ) - ); - t.assertTrue(e.errorDetail.code === TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED) - // FIXME #9683 We should not fail here + await wallet1.call(WalletApiOperation.GetWithdrawalDetailsForUri, { + talerWithdrawUri: withdrawal.taler_withdraw_uri, + }); + + await wallet1.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, { + exchangeBaseUrl: exchange.baseUrl, + talerWithdrawUri: withdrawal.taler_withdraw_uri, + }); } t.logStep("Check other wallet scan after completion"); { - await wallet2.call( - WalletApiOperation.GetWithdrawalDetailsForUri, - { - talerWithdrawUri: withdrawal.taler_withdraw_uri, - }, - ); + await wallet2.call(WalletApiOperation.GetWithdrawalDetailsForUri, { + talerWithdrawUri: withdrawal.taler_withdraw_uri, + }); // FIXME #9683 wallet should already know withdrawal already completed by wallet1 - const e = await t.assertThrowsTalerErrorAsyncLegacy( - wallet2.call( - WalletApiOperation.AcceptBankIntegratedWithdrawal, - { - exchangeBaseUrl: exchange.baseUrl, - talerWithdrawUri: withdrawal.taler_withdraw_uri, - }, - ) - ) - t.assertTrue(e.errorDetail.code === TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK) + const e = await t.assertThrowsTalerErrorAsync(async () => { + await wallet2.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, { + exchangeBaseUrl: exchange.baseUrl, + talerWithdrawUri: withdrawal.taler_withdraw_uri, + }); + }); + t.assertTrue( + e.errorDetail.code === + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + ); // FIXME #9683 can we have a more proper error code than this ? } diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -3708,6 +3708,19 @@ export async function prepareBankIntegratedWithdrawal( }; } + switch (info.status) { + case "aborted": + case "selected": + case "confirmed": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + `withdrawal is in status ${info.status}, unable to proceed`, + ); + default: + break; + } + /** * Withdrawal group without exchange and amount * this is an special case when the user haven't yet @@ -3932,76 +3945,79 @@ export async function confirmWithdrawal( ); } - let pending = false; - await ctx.transition({}, async (rec) => { - if (!rec) { - return TransitionResult.stay(); - } - switch (rec.status) { - case WithdrawalGroupStatus.PendingWaitConfirmBank: { - pending = true; + await ctx.transition( + { + extraStores: ["exchanges"], + }, + async (rec, tx) => { + if (!rec) { return TransitionResult.stay(); } - case WithdrawalGroupStatus.AbortedOtherWallet: { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, - {}, - ); - } - case WithdrawalGroupStatus.DialogProposed: { - rec.exchangeBaseUrl = exchange.exchangeBaseUrl; - rec.instructedAmount = req.amount; - rec.restrictAge = req.restrictAge; - if (initialDenoms != null) { - rec.denomsSel = initialDenoms; - rec.rawWithdrawalAmount = initialDenoms.totalWithdrawCost; - rec.effectiveWithdrawalAmount = initialDenoms.totalCoinValue; - } else { - rec.denomsSel = undefined; - rec.rawWithdrawalAmount = Amounts.stringify( - Amounts.zeroOfCurrency(instructedCurrency), + switch (rec.status) { + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.PendingRegisteringBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: { + // Be idempotent. + return TransitionResult.stay(); + } + case WithdrawalGroupStatus.AbortedOtherWallet: { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + } + case WithdrawalGroupStatus.DialogProposed: { + rec.exchangeBaseUrl = exchange.exchangeBaseUrl; + rec.instructedAmount = req.amount; + rec.restrictAge = req.restrictAge; + if (initialDenoms != null) { + rec.denomsSel = initialDenoms; + rec.rawWithdrawalAmount = initialDenoms.totalWithdrawCost; + rec.effectiveWithdrawalAmount = initialDenoms.totalCoinValue; + } else { + rec.denomsSel = undefined; + rec.rawWithdrawalAmount = Amounts.stringify( + Amounts.zeroOfCurrency(instructedCurrency), + ); + rec.effectiveWithdrawalAmount = Amounts.stringify( + Amounts.zeroOfCurrency(instructedCurrency), + ); + } + checkDbInvariant( + rec.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated, + "withdrawal type mismatch", ); - rec.effectiveWithdrawalAmount = Amounts.stringify( - Amounts.zeroOfCurrency(instructedCurrency), + rec.wgInfo.exchangeCreditAccounts = withdrawalAccountList; + rec.wgInfo.bankInfo.exchangePaytoUri = exchangePaytoUri; + rec.status = WithdrawalGroupStatus.PendingRegisteringBank; + + await internalPerformExchangeWasUsed( + wex, + tx, + exchange.exchangeBaseUrl, + withdrawalGroup, + ); + + return TransitionResult.transition(rec); + } + + default: { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + message: `unable to confirm withdrawal in current state`, + txState: computeWithdrawalTransactionStatus(rec), + debugStateNum: rec.status, + }, ); } - checkDbInvariant( - rec.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated, - "withdrawal type mismatch", - ); - rec.wgInfo.exchangeCreditAccounts = withdrawalAccountList; - rec.wgInfo.bankInfo.exchangePaytoUri = exchangePaytoUri; - rec.status = WithdrawalGroupStatus.PendingRegisteringBank; - pending = true; - return TransitionResult.transition(rec); - } - default: { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, - { - message: `unable to confirm withdrawal in current state`, - txState: computeWithdrawalTransactionStatus(rec), - debugStateNum: rec.status, - }, - ); } - } - }); + }, + ); await wex.taskScheduler.resetTaskRetries(ctx.taskId); - // FIXME: Merge with transaction above! - await wex.db.runReadWriteTx( - { storeNames: ["exchanges"] }, - async (tx) => - await internalPerformExchangeWasUsed( - wex, - tx, - exchange.exchangeBaseUrl, - withdrawalGroup, - ), - ); - return { transactionId: req.transactionId as TransactionIdStr, confirmTransferUrl: withdrawalGroup.wgInfo.bankInfo.confirmUrl,