taler-typescript-core

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

commit 03b41b41bf5c162aac6b626f90a8e2de77fe3c11
parent f529e0c83f37893b280adc7f830a69340f06d261
Author: Florian Dold <dold@taler.net>
Date:   Thu, 23 Jul 2026 01:01:18 +0200

harness: test progressToken retry, cancellation and phases

Diffstat:
Apackages/taler-harness/src/integrationtests/test-wallet-progress-token.ts | 354+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
2 files changed, 356 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-progress-token.ts b/packages/taler-harness/src/integrationtests/test-wallet-progress-token.ts @@ -0,0 +1,354 @@ +/* + 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/> + */ + +/** + * Imports. + */ +import { + AmountString, + Duration, + NotificationType, + TalerCorebankApiClient, + TalerError, + TalerErrorCode, + TalerMerchantInstanceHttpClient, + TemplateType, + WalletNotification, + succeedOrThrow, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import * as fs from "node:fs"; +import { + createSimpleTestkudosEnvironmentV3, + createWalletDaemonWithClient, +} from "../harness/environments.js"; +import { GlobalTestState, waitMs } from "../harness/harness.js"; + +/** + * Exercise the progressToken machinery of requests that talk to the network + * themselves: a request that has no transactionId still has to report failed + * attempts, retry on its own and be cancellable while it does so. + * + * Uses the start-tc dev experiment to make the exchange answer with errors + * that only retrying can get past. + */ +export async function runWalletProgressTokenTest(t: GlobalTestState) { + const tosDir = t.testDir + `/tos/`; + const tosContent = "terms of service"; + fs.mkdirSync(tosDir + "en/", { recursive: true }); + fs.writeFileSync(tosDir + "en/v1.txt", tosContent); + + const { exchange, bankClient, merchant, merchantAdminAccessToken } = + await createSimpleTestkudosEnvironmentV3(t, undefined, { + additionalExchangeConfig: (ex) => { + ex.changeConfig((cfg) => { + cfg.setString("exchange", "terms_etag", "v1"); + cfg.setString("exchange", "terms_dir", tosDir); + }); + }, + }); + + // The dev experiments that inject the failures need dev mode. + const { walletClient } = await createWalletDaemonWithClient(t, { + name: "w-progress", + config: { + testing: { + devModeActive: true, + }, + }, + }); + + const notifications: WalletNotification[] = []; + walletClient.addNotificationListener((n) => { + notifications.push(n); + }); + + const progressErrorsFor = (progressToken: string) => + notifications.filter( + (n) => + n.type === NotificationType.RequestProgressError && + n.progressToken === progressToken, + ); + + const phasesFor = (progressToken: string) => + notifications.filter( + (n) => + n.type === NotificationType.RequestProgressPhase && + n.progressToken === progressToken, + ); + + /** + * Notifications are delivered on a later tick than the response to the + * request that caused them, so anything emitted as a request finishes has + * to be waited for rather than read off right away. + */ + const waitForNotified = async (what: string, cond: () => boolean) => { + for (let i = 0; i < 100; i++) { + if (cond()) { + return; + } + await waitMs(50); + } + t.fail(`timed out waiting for ${what}`); + }; + + // Get the exchange into a ready state over a working network, so that the + // failures injected below only hit the ToS download and not the exchange + // update, which retries on its own schedule. + await walletClient.call(WalletApiOperation.AddExchange, { + exchangeBaseUrl: exchange.baseUrl, + }); + + // --------------------------------------------------------------------- + // A request with a progress token retries until it gets through. + // --------------------------------------------------------------------- + + // Two failures per URL, so the ToS download only succeeds on the third + // attempt. Without retries the request fails outright. + await walletClient.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: "taler://dev-experiment/start-tc?fake_500_count=2", + }); + + const tos = await walletClient.call(WalletApiOperation.GetExchangeTos, { + exchangeBaseUrl: exchange.baseUrl, + progressToken: "tok-retry", + }); + + t.assertDeepEqual(tos.content, tosContent); + + await waitForNotified( + "both failed attempts to be reported", + () => progressErrorsFor("tok-retry").length >= 2, + ); + // The counter is what a UI shows as "attempt n", so it has to advance. + t.assertTrue( + progressErrorsFor("tok-retry").some( + (n) => + n.type === NotificationType.RequestProgressError && n.retryCounter > 0, + ), + ); + await waitForNotified("the request to report itself done", () => + phasesFor("tok-retry").some( + (n) => + n.type === NotificationType.RequestProgressPhase && n.phase === "done", + ), + ); + + await walletClient.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: "taler://dev-experiment/stop-tc", + }); + + // --------------------------------------------------------------------- + // Cancelling takes effect during the backoff, not after it. + // --------------------------------------------------------------------- + + // Every request fails now, so the request below can only end by being + // cancelled. + await walletClient.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: "taler://dev-experiment/start-tc?fake_500=1", + }); + + const cancelledReq = walletClient.call(WalletApiOperation.GetExchangeTos, { + exchangeBaseUrl: exchange.baseUrl, + progressToken: "tok-cancel", + }); + // Don't let the rejection go unhandled while we wait below. + const cancelledReqResult = cancelledReq.then( + () => undefined, + (e) => e, + ); + + // Wait until the backoff has grown to a few seconds, so that a cancellation + // that only takes effect after the sleep is clearly distinguishable. + await walletClient.waitForNotificationCond( + (n) => + n.type === NotificationType.RequestProgressError && + n.progressToken === "tok-cancel" && + n.retryCounter >= 3, + ); + + await walletClient.call(WalletApiOperation.CancelProgressToken, { + operation: "getExchangeTos", + progressToken: "tok-cancel", + }); + + const cancelTimeout = Symbol("cancel-timeout"); + const cancelOutcome = await Promise.race([ + cancelledReqResult, + waitMs(2000).then(() => cancelTimeout), + ]); + + t.assertTrue( + cancelOutcome !== cancelTimeout, + "cancelled request did not return while the backoff was still running", + ); + t.assertTrue(cancelOutcome instanceof TalerError); + t.assertDeepEqual( + (cancelOutcome as TalerError).errorDetail.code, + TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED, + ); + + await walletClient.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: "taler://dev-experiment/stop-tc", + }); + + // --------------------------------------------------------------------- + // A request that failed stops reporting progress. + // --------------------------------------------------------------------- + + // Fails inside the progress context, before any network request, so the + // phase notifications are the only thing still running afterwards. + await t.assertThrowsAsync(async () => { + await walletClient.call(WalletApiOperation.CheckDeposit, { + depositPaytoUri: "not-a-payto-uri", + amount: "TESTKUDOS:5", + progressToken: "tok-failed", + }); + }); + + await waitForNotified("the failed request to report itself done", () => + phasesFor("tok-failed").some( + (n) => + n.type === NotificationType.RequestProgressPhase && n.phase === "done", + ), + ); + const phasesAtFailure = phasesFor("tok-failed").length; + + // The "delayed" phase is due 5s after the request started. Nothing may + // arrive after "done", or a client would keep showing progress for a + // request that is long gone. + await waitMs(6500); + t.assertDeepEqual(phasesFor("tok-failed").length, phasesAtFailure); + + // --------------------------------------------------------------------- + // A withdrawal-group request retries its own network work too. This one + // talks to the bank integration API rather than the exchange, so it covers + // a different network hop than the cases above. + // --------------------------------------------------------------------- + + const corebank = bankClient as TalerCorebankApiClient; + const bankUser = await corebank.createRandomBankUser(); + const userBank = new TalerCorebankApiClient(corebank.baseUrl); + userBank.setAuth({ + username: bankUser.username, + password: bankUser.password, + }); + const wop = await userBank.createWithdrawalOperation( + bankUser.username, + "TESTKUDOS:10", + ); + + // Fail the bank integration API twice per URL: getWithdrawalDetailsForUri + // only returns if it retries the bank query on its own. + await walletClient.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: "taler://dev-experiment/start-tc?fake_500_count=2", + }); + + const details = await walletClient.call( + WalletApiOperation.GetWithdrawalDetailsForUri, + { + talerWithdrawUri: wop.taler_withdraw_uri, + progressToken: "tok-withdraw", + }, + ); + t.assertAmountEquals(details.amount!, "TESTKUDOS:10"); + + await waitForNotified( + "the withdrawal-details request to report a failed attempt", + () => progressErrorsFor("tok-withdraw").length >= 1, + ); + + await walletClient.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: "taler://dev-experiment/stop-tc", + }); + + // --------------------------------------------------------------------- + // A peer-payment request carries a token through to completion. There is + // no cheap way to force a retry here (the peer contract endpoints answer a + // missing purse with a result case, not a 5xx that would retry), so this + // just confirms the token is accepted and the request reports itself done + // even when it ends in a terminal error. + // --------------------------------------------------------------------- + + await t.assertThrowsAsync(async () => { + await walletClient.call(WalletApiOperation.PreparePeerPushCredit, { + // Well-formed URI for our exchange, but no such purse exists. + talerUri: `taler+http://pay-push/localhost:8081/MQP1DP1J94ZZWNQS7TRDF1KJZ7V8H74CZF41V90FKXBPN5GNRN6G`, + progressToken: "tok-peer", + }); + }); + + await waitForNotified("the peer request to report itself done", () => + phasesFor("tok-peer").some( + (n) => + n.type === NotificationType.RequestProgressPhase && n.phase === "done", + ), + ); + + // --------------------------------------------------------------------- + // A merchant-facing request retries too. Unlike the peer contract + // endpoints, the merchant client raises on a 5xx (succeedOrThrow), so a + // fake_500 is retried here just like the bank query above. + // --------------------------------------------------------------------- + + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + succeedOrThrow( + await merchantClient.addTemplate(merchantAdminAccessToken, { + template_id: "tmpl-progress", + template_description: "progress token test template", + template_contract: { + template_type: TemplateType.FIXED_ORDER, + minimum_age: 0, + pay_duration: Duration.toTalerProtocolDuration( + Duration.fromSpec({ minutes: 2 }), + ), + summary: "progress token template", + }, + editable_defaults: { + amount: "TESTKUDOS:1" as AmountString, + }, + }), + ); + + await walletClient.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: "taler://dev-experiment/start-tc?fake_500_count=2", + }); + + const templateResult = await walletClient.call( + WalletApiOperation.CheckPayForTemplate, + { + talerPayTemplateUri: `taler+http://pay-template/localhost:${merchant.port}/tmpl-progress`, + progressToken: "tok-template", + }, + ); + t.assertDeepEqual( + templateResult.templateDetails.template_contract.summary, + "progress token template", + ); + + await waitForNotified( + "the template request to report a failed attempt", + () => progressErrorsFor("tok-template").length >= 1, + ); + + await walletClient.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: "taler://dev-experiment/stop-tc", + }); +} + +runWalletProgressTokenTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -193,6 +193,7 @@ import { runWalletDd48Test } from "./test-wallet-dd48.js"; import { runWalletDenomExpireTest } from "./test-wallet-denom-expire.js"; import { runWalletDepositLargeTest } from "./test-wallet-deposit-large.js"; import { runWalletBadNetworkTest } from "./test-wallet-bad-network.js"; +import { runWalletProgressTokenTest } from "./test-wallet-progress-token.js"; import { runWalletDevExperimentsTest } from "./test-wallet-dev-experiments.js"; import { runWalletDevexpFakeprotoverTest } from "./test-wallet-devexp-fakeprotover.js"; import { runWalletExchangeFeaturesTest } from "./test-wallet-exchange-features.js"; @@ -323,6 +324,7 @@ const allTests: TestMainFunction[] = [ runExchangeManagementTest, runWalletObservabilityTest, runWalletBadNetworkTest, + runWalletProgressTokenTest, runWalletDevExperimentsTest, runWalletBalanceZeroTest, runWalletInsufficientBalanceTest,