commit d30b2c3212023350015053547a5f047210bd2fe5
parent 9e65a25e0c1ca609351a4db2d78c20e29a34a0c7
Author: Florian Dold <florian@dold.me>
Date: Fri, 17 Oct 2025 16:03:45 +0200
wallet-core: reproducer for merchant donau idempotency on /pay
Diffstat:
7 files changed, 334 insertions(+), 22 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-donau-idempotency.ts b/packages/taler-harness/src/integrationtests/test-donau-idempotency.ts
@@ -0,0 +1,248 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020-2025 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 {
+ AccessToken,
+ AmountString,
+ DonauHttpClient,
+ j2s,
+ MerchantContractOutputType,
+ MerchantContractVersion,
+ OrderOutputType,
+ OrderVersion,
+ PreparePayResultType,
+ succeedOrThrow,
+ TalerMerchantInstanceHttpClient,
+ TransactionMajorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/environments.js";
+import { DonauService } from "../harness/harness-donau.js";
+import { delayMs, GlobalTestState } from "../harness/harness.js";
+
+export async function runDonauIdempotencyTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ walletClient,
+ bankClient,
+ exchange,
+ merchant,
+ merchantAdminAccessToken,
+ commonDb,
+ } = await createSimpleTestkudosEnvironmentV3(t, undefined, {
+ merchantUseDonau: true,
+ walletConfig: {
+ testing: {
+ devModeActive: true,
+ },
+ features: {
+ enableV1Contracts: true,
+ },
+ },
+ });
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ amount: "TESTKUDOS:100",
+ exchange,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const donau = DonauService.create(t, {
+ currency: "TESTKUDOS",
+ database: commonDb.connStr,
+ httpPort: 8084,
+ name: "donau",
+ domain: "Bern",
+ });
+
+ donau.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS")));
+
+ await donau.start();
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ const inst = succeedOrThrow(
+ await merchantClient.getCurrentInstanceDetails(merchantAdminAccessToken),
+ );
+ const merchantPub = inst.merchant_pub;
+
+ const donauClient = new DonauHttpClient(donau.baseUrl);
+
+ const currentYear = new Date().getFullYear();
+
+ const charityResp = succeedOrThrow(
+ await donauClient.createCharity("" as AccessToken, {
+ charity_pub: merchantPub,
+ current_year: currentYear,
+ max_per_year: "TESTKUDOS:1000",
+ charity_name: "42",
+ receipts_to_date: "TESTKUDOS:0",
+ charity_url: merchant.makeInstanceBaseUrl(),
+ }),
+ );
+
+ const config = await donauClient.getConfig();
+ console.log(`config: ${j2s(config)}`);
+
+ const keys = await donauClient.getKeys();
+ console.log(`keys: ${j2s(keys)}`);
+
+ const charityId = charityResp["charity_id"];
+
+ succeedOrThrow(
+ await merchantClient.postDonau({
+ body: {
+ charity_id: charityId,
+ donau_url: donau.baseUrl,
+ },
+ token: merchantAdminAccessToken,
+ }),
+ );
+
+ // Do it twice to check idempotency
+ succeedOrThrow(
+ await merchantClient.postDonau({
+ body: {
+ charity_id: charityId,
+ donau_url: donau.baseUrl,
+ },
+ token: merchantAdminAccessToken,
+ }),
+ );
+
+ // Wait for donaukeysupdate
+ // We don't use -t since it doesn't seem to work at the moment.
+ await delayMs(2000);
+
+ {
+ // Just test the GetDonau request (initial)
+ const getRes = await walletClient.call(WalletApiOperation.GetDonau, {});
+ t.assertDeepEqual(getRes.currentDonauInfo, undefined);
+ }
+
+ await walletClient.call(WalletApiOperation.SetDonau, {
+ donauBaseUrl: donau.baseUrl,
+ taxPayerId: "test-tax-payer",
+ });
+
+ const amounts: AmountString[] = [
+ "TESTKUDOS:1",
+ "TESTKUDOS:9.42",
+ "TESTKUDOS:0.1",
+ ];
+
+ await walletClient.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/block-pay-response",
+ });
+
+ for (let i = 0; i < amounts.length; i++) {
+ const orderResp = succeedOrThrow(
+ await merchantClient.createOrder(merchantAdminAccessToken, {
+ order: {
+ version: OrderVersion.V1,
+ summary: "Test Donation",
+ choices: [
+ {
+ amount: amounts[i],
+ outputs: [
+ {
+ type: OrderOutputType.TaxReceipt,
+ amount: amounts[i],
+ donau_urls: [donau.baseUrl],
+ },
+ ],
+ },
+ ],
+ },
+ }),
+ );
+
+ console.log(`order resp: ${j2s(orderResp)}`);
+
+ let orderStatus = succeedOrThrow(
+ await merchantClient.getOrderDetails(
+ merchantAdminAccessToken,
+ orderResp.order_id,
+ ),
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ console.log(`preparePayResult: ${j2s(preparePayResult)}`);
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.ChoiceSelection,
+ );
+
+ t.assertDeepEqual(
+ preparePayResult.contractTerms.version,
+ MerchantContractVersion.V1,
+ );
+ const outTok = preparePayResult.contractTerms.choices[0].outputs[0];
+ t.assertDeepEqual(outTok.type, MerchantContractOutputType.TaxReceipt);
+
+ t.assertDeepEqual(outTok.donau_urls, [donau.baseUrl]);
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ choiceIndex: 0,
+ useDonau: true,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: preparePayResult.transactionId,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: "*",
+ },
+ requireError: true,
+ });
+ }
+
+ // Disable dev experiment
+ await walletClient.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/block-pay-response?val=0",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const statements = await walletClient.call(
+ WalletApiOperation.GetDonauStatements,
+ {},
+ );
+ console.log(j2s(statements));
+}
+
+runDonauIdempotencyTest.suites = ["donau"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -53,6 +53,7 @@ import { runDepositTooLargeTest } from "./test-deposit-too-large.js";
import { runDepositTest } from "./test-deposit.js";
import { runDonauCharityManagementTest } from "./test-donau-charity-management.js";
import { runDonauCompatTest } from "./test-donau-compat.js";
+import { runDonauIdempotencyTest } from "./test-donau-idempotency.js";
import { runDonauMinusTTest } from "./test-donau-minus-t.js";
import { runDonauMultiTest } from "./test-donau-multi.js";
import { runDonauTest } from "./test-donau.js";
@@ -108,8 +109,8 @@ import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls.js"
import { runMerchantInstancesTest } from "./test-merchant-instances.js";
import { runMerchantLongpollingTest } from "./test-merchant-longpolling.js";
import { runMerchantRefundApiTest } from "./test-merchant-refund-api.js";
-import { runMerchantSelfProvisionActivationTest } from "./test-merchant-self-provision-activation.js";
import { runMerchantSelfProvisionActivationAndLoginTest } from "./test-merchant-self-provision-activation-and-login.js";
+import { runMerchantSelfProvisionActivationTest } from "./test-merchant-self-provision-activation.js";
import { runMerchantSelfProvisionForgotPasswordTest } from "./test-merchant-self-provision-forgot-password.js";
import { runMerchantSelfProvisionInactiveAccountPermissionsTest } from "./test-merchant-self-provision-inactive-account-permissions.js";
import { runMerchantSpecPublicOrdersTest } from "./test-merchant-spec-public-orders.js";
@@ -391,6 +392,7 @@ const allTests: TestMainFunction[] = [
runDonauMinusTTest,
runDonauCharityManagementTest,
runDonauMultiTest,
+ runDonauIdempotencyTest,
];
export interface TestRunSpec {
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -2625,7 +2625,7 @@ export interface CheckPaymentUnpaidResponse {
// Deadline when the offer expires; the customer must pay before.
// @since protocol **v21**.
- pay_deadline: Timestamp;
+ pay_deadline: Timestamp | undefined;
// Order summary text.
summary: string;
@@ -4156,13 +4156,13 @@ export const codecForCheckPaymentUnpaidResponse =
.property("order_status", codecForConstString("unpaid"))
.property("taler_pay_uri", codecForTalerUriString())
.property("creation_time", codecForTimestamp)
- .property("pay_deadline", codecForTimestamp)
+ .property("pay_deadline", codecOptional(codecForTimestamp))
.property("summary", codecForString())
.property("total_amount", codecForAmountString())
.property("already_paid_order_id", codecOptional(codecForString()))
.property("already_paid_fulfillment_url", codecOptional(codecForString()))
.property("order_status_url", codecForString())
- .build("TalerMerchantApi.CheckPaymentPaidResponse");
+ .build("TalerMerchantApi.CheckPaymentUnpaidResponse");
export const codecForCheckPaymentClaimedResponse =
(): Codec<CheckPaymentClaimedResponse> =>
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -3764,6 +3764,12 @@ export interface TestingWaitTransactionRequest {
*/
timeout?: DurationUnitSpec;
+ /**
+ * If set to true, wait until the desired state
+ * is reached with an error.
+ */
+ requireError?: boolean;
+
txState: TransactionStatePattern | TransactionStatePattern[] | number;
}
diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts
@@ -37,9 +37,11 @@ import {
MerchantContractVersion,
PeerContractTerms,
RefreshReason,
+ TalerErrorCode,
TalerPreciseTimestamp,
encodeCrock,
getRandomBytes,
+ j2s,
parseDevExperimentUri,
} from "@gnu-taler/taler-util";
import {
@@ -300,14 +302,13 @@ export async function applyDevExperiment(
return;
}
case "flag-confirm-pay-no-wait": {
- const setVal = parsedUri.query?.get("val");
- if (setVal === "0") {
- wex.ws.devExperimentState.flagConfirmPayNoWait = false;
- } else if (setVal === "1") {
- wex.ws.devExperimentState.flagConfirmPayNoWait = true;
- } else {
- throw Error("param 'val' must be 0 or 1");
- }
+ wex.ws.devExperimentState.flagConfirmPayNoWait = getValFlag(parsedUri);
+ return;
+ }
+ case "block-pay-response": {
+ const val = getValFlag(parsedUri);
+ logger.info(`setting dev experiment blockPayResponse=${val}`);
+ wex.ws.devExperimentState.blockPayResponse = val;
return;
}
case "pretend-no-denoms": {
@@ -321,6 +322,19 @@ export async function applyDevExperiment(
}
}
+function getValFlag(parsedUri: DevExperimentUri): boolean {
+ const setVal = parsedUri.query?.get("val");
+ if (setVal == null) {
+ return true;
+ } else if (setVal === "0") {
+ return false;
+ } else if (setVal === "1") {
+ return true;
+ } else {
+ throw Error("param 'val' must be 0 or 1");
+ }
+}
+
async function addFakeTx(
wex: WalletExecutionContext,
parsedUri: DevExperimentUri,
@@ -638,6 +652,30 @@ function mockResponseJson(resp: HttpResponse, respJson: any): HttpResponse {
};
}
+function mockInternalServerError(resp: HttpResponse): HttpResponse {
+ const textEncoder = new TextEncoder();
+ const respJson = {
+ code: TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE,
+ hint: "internal server error (test mock)",
+ message: "internal server error (test mock)",
+ };
+ return {
+ requestMethod: resp.requestMethod,
+ requestUrl: resp.requestUrl,
+ status: 500,
+ headers: resp.headers,
+ async bytes() {
+ return textEncoder.encode(JSON.stringify(respJson, undefined, 2));
+ },
+ async json() {
+ return respJson;
+ },
+ async text() {
+ return JSON.stringify(respJson, undefined, 2);
+ },
+ };
+}
+
export class DevExperimentHttpLib implements HttpRequestLibrary {
_isDevExperimentLib = true;
underlyingLib: HttpRequestLibrary;
@@ -653,8 +691,11 @@ export class DevExperimentHttpLib implements HttpRequestLibrary {
url: string,
opt?: HttpRequestOptions | undefined,
): Promise<HttpResponse> {
+ logger.warn(`dev experiment request ${url}`);
+ logger.info(`devExperimentState: ${j2s(this.devExperimentState)}`);
+ const method = (opt?.method ?? "get").toLowerCase();
if (this.devExperimentState.fakeProtoVer != null) {
- if ((opt?.method ?? "get").toLowerCase() == "get") {
+ if (method == "get") {
let verBaseUrl: string | undefined;
const confSuffix = "/config";
const keysSuffix = "/keys";
@@ -676,6 +717,14 @@ export class DevExperimentHttpLib implements HttpRequestLibrary {
return mockResponseJson(resp, respJson);
}
}
+ } else if (this.devExperimentState.blockPayResponse) {
+ logger.warn(`have blockPayResponse`);
+ logger.info(`endsWithPay: ${url.endsWith("/pay")}`);
+ if (method === "post" && url.endsWith("/pay")) {
+ logger.warn(`blocking /pay response`);
+ const realResp = await this.underlyingLib.fetch(url, opt);
+ return mockInternalServerError(realResp);
+ }
}
return this.underlyingLib.fetch(url, opt);
}
diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts
@@ -664,6 +664,11 @@ export async function waitTransactionState(
tx.txState,
)} (update logId: ${logId})`,
);
+ if (req.requireError) {
+ if (tx.error == null) {
+ return false;
+ }
+ }
if (Array.isArray(txState)) {
for (const myState of txState) {
if (matchState(tx.txState, myState)) {
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -306,7 +306,11 @@ import {
generateDepositGroupTxId,
} from "./deposits.js";
import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js";
-import { handleGetDonau, handleGetDonauStatements, handleSetDonau } from "./donau.js";
+import {
+ handleGetDonau,
+ handleGetDonauStatements,
+ handleSetDonau,
+} from "./donau.js";
import {
ReadyExchangeSummary,
acceptExchangeTermsOfService,
@@ -383,6 +387,7 @@ import {
waitUntilRefreshesDone,
withdrawTestBalance,
} from "./testing.js";
+import { deleteDiscount, listDiscounts } from "./tokenFamilies.js";
import {
abortTransaction,
deleteTransaction,
@@ -419,7 +424,6 @@ import {
getWithdrawalDetailsForUri,
prepareBankIntegratedWithdrawal,
} from "./withdraw.js";
-import { deleteDiscount, listDiscounts } from "./tokenFamilies.js";
const logger = new Logger("wallet.ts");
@@ -1277,19 +1281,14 @@ async function handleListDiscounts(
wex: WalletExecutionContext,
req: ListDiscountsRequest,
): Promise<ListDiscountsResponse> {
- return await listDiscounts(wex,
- req.tokenIssuePubHash,
- req.merchantBaseUrl,
- );
+ return await listDiscounts(wex, req.tokenIssuePubHash, req.merchantBaseUrl);
}
async function handleDeleteDiscount(
wex: WalletExecutionContext,
req: DeleteDiscountRequest,
): Promise<EmptyObject> {
- return await deleteDiscount(wex,
- req.tokenFamilyHash,
- );
+ return await deleteDiscount(wex, req.tokenFamilyHash);
}
async function handleAbortTransaction(
@@ -2765,6 +2764,8 @@ export interface DevExperimentState {
}
>;
+ blockPayResponse?: boolean;
+
/** Migration test for confirmPay */
flagConfirmPayNoWait?: boolean;
}
@@ -2976,6 +2977,7 @@ export class InternalWalletState {
this._http = this.httpFactory(newConfig);
if (this.config.testing.devModeActive) {
+ logger.warn("using dev experiment http lib");
this._http = new DevExperimentHttpLib(this.http, this.devExperimentState);
}
}