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:
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,