commit 0394aa0cd076a17e5c1cf983bc9a710b787bfc7e
parent a033009111114db0394e675ddb783ebb70eba557
Author: Florian Dold <dold@taler.net>
Date: Wed, 29 Jul 2026 12:47:01 +0200
harness: check that a deleted order settles the payment transaction
Issue: https://bugs.taler.net/n/10261
Diffstat:
2 files changed, 201 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-payment-order-gone.ts b/packages/taler-harness/src/integrationtests/test-payment-order-gone.ts
@@ -0,0 +1,199 @@
+/*
+ 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,
+ ConfirmPayResultType,
+ succeedOrThrow,
+ TalerMerchantInstanceHttpClient,
+ TransactionMajorState,
+ TransactionMinorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/environments.js";
+import { GlobalTestState } from "../harness/harness.js";
+
+/**
+ * Once the merchant has deleted an order, it answers every request about it
+ * with a 404. The wallet must take that as final and stop asking: a wallet
+ * that keeps polling for its old orders puts load on the merchant backend
+ * that grows with the number of wallets, and that no merchant can shed.
+ */
+export async function runPaymentOrderGoneTest(t: GlobalTestState) {
+ const {
+ walletClient,
+ bankClient,
+ exchange,
+ merchant,
+ merchantAdminAccessToken,
+ } = await createSimpleTestkudosEnvironmentV3(t, undefined, {
+ // The merchant keeps a paid order around for its legal preservation
+ // period and refuses to delete it before that. Cut the period short, so
+ // that the test can produce the order the merchant no longer has.
+ additionalMerchantConfig(m) {
+ m.modifyConfig(async (cfg) => {
+ cfg.setString("merchant", "LEGAL_PRESERVATION", "0 s");
+ });
+ },
+ });
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ async function createOrder(amount: AmountString): Promise<{
+ orderId: string;
+ talerPayUri: string;
+ }> {
+ const orderResp = succeedOrThrow(
+ await merchantClient.createOrder(merchantAdminAccessToken, {
+ order: {
+ summary: "Buy me!",
+ amount,
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ }),
+ );
+ const orderStatus = succeedOrThrow(
+ await merchantClient.getOrderDetails(
+ merchantAdminAccessToken,
+ orderResp.order_id,
+ ),
+ );
+ t.assertTrue(orderStatus.order_status === "unpaid");
+ return {
+ orderId: orderResp.order_id,
+ talerPayUri: orderStatus.taler_pay_uri,
+ };
+ }
+
+ t.logStep("setup-done");
+
+ // Case 1: the order is deleted after it was paid, and the wallet is then
+ // asked to check for a refund. The payment stays valid, so the transaction
+ // goes back to being done instead of querying the merchant forever.
+ {
+ const order = await createOrder("TESTKUDOS:5" as AmountString);
+
+ const prep = await walletClient.call(
+ WalletApiOperation.PreparePayForUriV2,
+ { talerPayUri: order.talerPayUri },
+ );
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prep.transactionId,
+ txState: {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ },
+ });
+ const confirmResp = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: prep.transactionId,
+ choiceIndex: 0,
+ });
+ t.assertTrue(confirmResp.type === ConfirmPayResultType.Done);
+
+ succeedOrThrow(
+ await merchantClient.deleteOrder(
+ merchantAdminAccessToken,
+ order.orderId,
+ true,
+ ),
+ );
+
+ t.logStep("paid-order-deleted");
+
+ await walletClient.call(WalletApiOperation.StartRefundQuery, {
+ transactionId: prep.transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prep.transactionId,
+ txState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+
+ const tx = await walletClient.call(WalletApiOperation.GetTransactionById, {
+ transactionId: prep.transactionId,
+ });
+ t.assertTrue(tx.type === "payment");
+ t.assertTrue(tx.refundQueryActive === false);
+
+ t.logStep("refund-query-given-up");
+ }
+
+ // Case 2: the order is deleted while a shared proposal is waiting for
+ // another wallet to pay it. Nobody can pay a deleted order, so the wallet
+ // must stop watching it.
+ {
+ const order = await createOrder("TESTKUDOS:5" as AmountString);
+
+ const prep = await walletClient.call(
+ WalletApiOperation.PreparePayForUriV2,
+ { talerPayUri: order.talerPayUri },
+ );
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prep.transactionId,
+ txState: {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ },
+ });
+
+ succeedOrThrow(
+ await merchantClient.deleteOrder(
+ merchantAdminAccessToken,
+ order.orderId,
+ false,
+ ),
+ );
+
+ t.logStep("shared-order-deleted");
+
+ await walletClient.call(WalletApiOperation.SharePayment, {
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ orderId: order.orderId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prep.transactionId,
+ txState: {
+ major: TransactionMajorState.Aborted,
+ },
+ });
+
+ t.logStep("shared-proposal-given-up");
+ }
+
+ // No task may be left polling the merchant for either order.
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runPaymentOrderGoneTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -150,6 +150,7 @@ import { runPaymentExpiredTest } from "./test-payment-expired.js";
import { runPaymentForgettableTest } from "./test-payment-forgettable.js";
import { runPaymentIdempotencyTest } from "./test-payment-idempotency.js";
import { runPaymentMultipleTest } from "./test-payment-multiple.js";
+import { runPaymentOrderGoneTest } from "./test-payment-order-gone.js";
import { runPaymentShareIdempotencyTest } from "./test-payment-share-idempotency.js";
import { runPaymentShareTest } from "./test-payment-share.js";
import { runPaymentTemplateTest } from "./test-payment-template.js";
@@ -305,6 +306,7 @@ const allTests: TestMainFunction[] = [
runPaymentForgettableTest,
runPaymentIdempotencyTest,
runPaymentMultipleTest,
+ runPaymentOrderGoneTest,
runPaymentTest,
runPaymentShareTest,
runPaymentShareIdempotencyTest,