taler-typescript-core

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

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:
Apackages/taler-harness/src/integrationtests/test-donau-idempotency.ts | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 4+++-
Mpackages/taler-util/src/types-taler-merchant.ts | 6+++---
Mpackages/taler-util/src/types-taler-wallet.ts | 6++++++
Mpackages/taler-wallet-core/src/dev-experiments.ts | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mpackages/taler-wallet-core/src/testing.ts | 5+++++
Mpackages/taler-wallet-core/src/wallet.ts | 20+++++++++++---------
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); } }