taler-typescript-core

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

commit 37019e79814239d3468bf6c928a6a25fd4d831ff
parent 74490cf8943d716ccffd2a9db0d204ab0aa4b5aa
Author: Florian Dold <dold@taler.net>
Date:   Fri, 11 Sep 2026 20:27:40 +0200

taler-harness: cover cumulative withdrawal KYC previews

Exercise two withdrawals of 100 against a daily withdrawal limit of 150.
Spend the first withdrawal and restart a persistent wallet before checking
the second preview, with variants for SQLite and IndexedDB.

Cover repeated previews, a smaller amount, sender isolation, the eventual
KYC requirement, and updated account rules after an AML decision.

Issue: https://bugs.taler.net/n/10489

Diffstat:
Mpackages/taler-harness/README.md | 8+++++---
Mpackages/taler-harness/src/harness/environments.ts | 9+++++++--
Apackages/taler-harness/src/integrationtests/test-kyc-withdrawal-cumulative.ts | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 3+++
4 files changed, 244 insertions(+), 5 deletions(-)

diff --git a/packages/taler-harness/README.md b/packages/taler-harness/README.md @@ -93,9 +93,11 @@ new test reports "selected 0 tests". ### Wallet database coverage Fresh Node.js wallets use native SQLite by default. The `simple-payment`, -`refund`, `peer-push`, `peer-pull`, and `wallet-refresh-errors` tests also have -`-indexeddb` variants that run automatically in the same suites. The last test -includes database export/import as well as refresh error handling. +`refund`, `peer-push`, `peer-pull`, `wallet-refresh-errors`, and +`kyc-withdrawal-cumulative` tests also have `-indexeddb` variants that run +automatically in the same suites. `wallet-refresh-errors` includes database +export/import as well as refresh error handling. `kyc-withdrawal-cumulative` +checks withdrawal-volume warnings after spending coins and restarting the wallet. Each original case explicitly uses `TALER_WALLET_DB_BACKEND=default`, and its variant uses `indexeddb`. Both disable automatic migration for the duration of diff --git a/packages/taler-harness/src/harness/environments.ts b/packages/taler-harness/src/harness/environments.ts @@ -934,6 +934,7 @@ function defaultOnNotification(n: WalletNotification): void { } export interface KycEnvOptions { + walletPersistent?: boolean; coinConfig?: CoinConfig[]; onWalletNotification?: (n: WalletNotification) => void; adjustExchangeConfig?(config: Configuration): void; @@ -1028,7 +1029,7 @@ export async function createKycTestkudosEnvironmentFull( const walletService = new WalletService(t, { name: "wallet", - useInMemoryDb: true, + useInMemoryDb: !opts.walletPersistent, }); await walletService.start(); await walletService.pingUntilAvailable(); @@ -1039,7 +1040,7 @@ export async function createKycTestkudosEnvironmentFull( onNotification: opts.onWalletNotification ?? defaultOnNotification, }); await walletClient.connect(); - await walletClient.client.call(WalletApiOperation.InitWallet, { + const init = await walletClient.client.call(WalletApiOperation.InitWallet, { config: { testing: { skipDefaults: true, @@ -1047,6 +1048,10 @@ export async function createKycTestkudosEnvironmentFull( }, }); + if (t.expectedWalletDbBackend !== undefined) { + t.assertDeepEqual(init.databaseBackend, t.expectedWalletDbBackend); + } + const merchant = await MerchantService.create(t, { name: "testmerchant-1", httpPort: 8083, diff --git a/packages/taler-harness/src/integrationtests/test-kyc-withdrawal-cumulative.ts b/packages/taler-harness/src/integrationtests/test-kyc-withdrawal-cumulative.ts @@ -0,0 +1,229 @@ +/* + This file is part of GNU Taler + (C) 2026 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 <http://www.gnu.org/licenses/> + */ + +import { + Amounts, + TalerProtocolTimestamp, + TransactionMajorState, + TransactionMinorState, + TransactionType, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { + configureSimpleKycInfoRule, + createKycTestkudosEnvironmentFull, + postAmlDecisionNoRules, +} from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; + +/** Regression for https://bugs.gnunet.org/view.php?id=10489. */ +export async function runKycWithdrawalCumulativeTest(t: GlobalTestState) { + const { walletClient, walletService, bankClient, exchange, amlKeypair } = + await createKycTestkudosEnvironmentFull(t, { + walletPersistent: true, + adjustExchangeConfig(config) { + configureSimpleKycInfoRule(config, "withdraw"); + config.setString("KYC-RULE-R1", "threshold", "TESTKUDOS:150"); + }, + }); + const user = await bankClient.createRandomBankUser(); + bankClient.setAuth({ username: user.username, password: user.password }); + const first = await bankClient.createWithdrawalOperation( + user.username, + "TESTKUDOS:100", + ); + const preparedFirst = await walletClient.call( + WalletApiOperation.PrepareBankIntegratedWithdrawal, + { + talerWithdrawUri: first.taler_withdraw_uri, + }, + ); + const firstPreview = await walletClient.call( + WalletApiOperation.GetWithdrawalDetailsForAmount, + { + exchangeBaseUrl: exchange.baseUrl, + transactionId: preparedFirst.transactionId, + amount: "TESTKUDOS:100", + }, + ); + t.assertDeepEqual(firstPreview.kycRequired, false); + t.assertDeepEqual(firstPreview.withdrawalKycStatus, "unknown"); + await walletClient.call(WalletApiOperation.ConfirmWithdrawal, { + transactionId: preparedFirst.transactionId, + exchangeBaseUrl: exchange.baseUrl, + amount: "TESTKUDOS:100", + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: preparedFirst.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankConfirmTransfer, + }, + }); + await bankClient.confirmWithdrawalOperation(user.username, { + withdrawalOperationId: first.withdrawal_id, + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: preparedFirst.transactionId, + txState: { major: TransactionMajorState.Done }, + }); + + // Spending the coins must not reset the withdrawal-volume allowance. + const spent = await walletClient.call(WalletApiOperation.CreateDepositGroup, { + amount: "TESTKUDOS:90", + depositPaytoUri: user.accountPaytoUri, + wireDeadline: TalerProtocolTimestamp.fromSeconds( + Math.floor(Date.now() / 1000) + 3600, + ), + }); + // The coins are spent once deposited; bank settlement is not needed here. + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: spent.transactionId, + txState: { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.Track, + }, + }); + await walletService.stop(); + await walletService.start(); + await walletService.pingUntilAvailable(); + await walletClient.connect(); + const init = await walletClient.call(WalletApiOperation.InitWallet, { + config: { testing: { skipDefaults: true } }, + }); + t.assertDeepEqual(init.databaseBackend, t.expectedWalletDbBackend); + + const second = await bankClient.createWithdrawalOperation( + user.username, + "TESTKUDOS:100", + ); + const preparedSecond = await walletClient.call( + WalletApiOperation.PrepareBankIntegratedWithdrawal, + { + talerWithdrawUri: second.taler_withdraw_uri, + }, + ); + const previewRequest = { + exchangeBaseUrl: exchange.baseUrl, + transactionId: preparedSecond.transactionId, + amount: "TESTKUDOS:100" as const, + }; + const before = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: preparedSecond.transactionId }, + ); + for (let i = 0; i < 2; i++) { + const preview = await walletClient.call( + WalletApiOperation.GetWithdrawalDetailsForAmount, + previewRequest, + ); + t.assertDeepEqual(preview.kycRequired, true); + t.assertDeepEqual(preview.withdrawalKycStatus, "kyc-required"); + t.assertTrue( + Amounts.cmp(preview.balanceKyc!.projectedBalance, "TESTKUDOS:150") < 0, + ); + } + const after = await walletClient.call(WalletApiOperation.GetTransactionById, { + transactionId: preparedSecond.transactionId, + }); + t.assertDeepEqual(after, before); + const below = await walletClient.call( + WalletApiOperation.GetWithdrawalDetailsForAmount, + { ...previewRequest, amount: "TESTKUDOS:40" }, + ); + t.assertDeepEqual(below.kycRequired, false); + t.assertDeepEqual(below.withdrawalKycStatus, "ok"); + + // A different sender cannot inherit Alice's volume or authentication key. + const other = await bankClient.createRandomBankUser(); + bankClient.setAuth({ username: other.username, password: other.password }); + const otherOp = await bankClient.createWithdrawalOperation( + other.username, + "TESTKUDOS:100", + ); + const otherPrepared = await walletClient.call( + WalletApiOperation.PrepareBankIntegratedWithdrawal, + { talerWithdrawUri: otherOp.taler_withdraw_uri }, + ); + const otherPreview = await walletClient.call( + WalletApiOperation.GetWithdrawalDetailsForAmount, + { ...previewRequest, transactionId: otherPrepared.transactionId }, + ); + t.assertDeepEqual(otherPreview.kycRequired, false); + t.assertDeepEqual(otherPreview.withdrawalKycStatus, "unknown"); + await walletClient.call(WalletApiOperation.AbortTransaction, { + transactionId: otherPrepared.transactionId, + }); + bankClient.setAuth({ username: user.username, password: user.password }); + + await walletClient.call(WalletApiOperation.ConfirmWithdrawal, { + transactionId: preparedSecond.transactionId, + exchangeBaseUrl: exchange.baseUrl, + amount: "TESTKUDOS:100", + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: preparedSecond.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankConfirmTransfer, + }, + }); + await bankClient.confirmWithdrawalOperation(user.username, { + withdrawalOperationId: second.withdrawal_id, + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: preparedSecond.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.KycRequired, + }, + }); + const blocked = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: preparedSecond.transactionId }, + ); + t.assertDeepEqual(blocked.type, TransactionType.Withdrawal); + t.assertTrue(!!blocked.kycPaytoHash); + await postAmlDecisionNoRules(t, { + exchangeBaseUrl: exchange.baseUrl, + paytoHash: blocked.kycPaytoHash, + amlPriv: amlKeypair.priv, + amlPub: amlKeypair.pub, + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: preparedSecond.transactionId, + txState: { major: TransactionMajorState.Done }, + }); + const third = await bankClient.createWithdrawalOperation( + user.username, + "TESTKUDOS:100", + ); + const preparedThird = await walletClient.call( + WalletApiOperation.PrepareBankIntegratedWithdrawal, + { talerWithdrawUri: third.taler_withdraw_uri }, + ); + const cleared = await walletClient.call( + WalletApiOperation.GetWithdrawalDetailsForAmount, + { ...previewRequest, transactionId: preparedThird.transactionId }, + ); + t.assertDeepEqual(cleared.kycRequired, false); + t.assertDeepEqual(cleared.withdrawalKycStatus, "ok"); + await walletClient.call(WalletApiOperation.AbortTransaction, { + transactionId: preparedThird.transactionId, + }); +} + +runKycWithdrawalCumulativeTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -118,6 +118,7 @@ import { runKycPeerPushTest } from "./test-kyc-peer-push.js"; import { runKycSkipExpirationTest } from "./test-kyc-skip-expiration.js"; import { runKycTwoFormsTest } from "./test-kyc-two-forms.js"; import { runKycWalletDepositAbortTest } from "./test-kyc-wallet-deposit-abort.js"; +import { runKycWithdrawalCumulativeTest } from "./test-kyc-withdrawal-cumulative.js"; import { runKycWithdrawalVerbotenTest } from "./test-kyc-withdrawal-verboten.js"; import { runLibeufinBankTest } from "./test-libeufin-bank.js"; import { runLibeufinConversionTest } from "./test-libeufin-conversion.js"; @@ -460,6 +461,8 @@ const allTests: TestMainFunction[] = [ runKycBalanceWithdrawalTest, runKycBalanceWithdrawalCumulativeTest, runKycBalanceWithdrawalZeroLimitTest, + withWalletDbBackend(runKycWithdrawalCumulativeTest, "default"), + withWalletDbBackend(runKycWithdrawalCumulativeTest, "indexeddb"), runKycNewMeasureTest, runKycSkipExpirationTest, runKycTwoFormsTest,