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