taler-typescript-core

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

commit f6b59b8b96fcdd4d87fb1e76f32f6b03dcb506be
parent 308b2b3dab853a9505e4cd9343ed0715e112e6be
Author: Florian Dold <dold@taler.net>
Date:   Thu,  6 Aug 2026 18:26:41 +0200

harness: check that a cherry-picked /keys keeps the older denominations

Issue: https://bugs.taler.net/n/11715

Diffstat:
Apackages/taler-harness/src/integrationtests/test-exchange-keys-cherrypick.ts | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
2 files changed, 314 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-exchange-keys-cherrypick.ts b/packages/taler-harness/src/integrationtests/test-exchange-keys-cherrypick.ts @@ -0,0 +1,312 @@ +/* + 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 { + Duration, + TalerCorebankApiClient, + URL, + j2s, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { + applyTimeTravelV2, + createWalletDaemonWithClient, + withdrawViaBankV3, +} from "../harness/environments.js"; +import { FaultInjectedExchangeService } from "../harness/faultInjection.js"; +import { + BankService, + ExchangeService, + GlobalTestState, + getTestHarnessPaytoForLabel, + setupDb, +} from "../harness/harness.js"; + +/** + * One observed request to /keys. + */ +interface KeysRequestRecord { + /** Value of the last_issue_date query parameter, if any. */ + lastIssueDate: string | undefined; + /** Size of the response body, to see cherry-picking actually save data. */ + responseSize: number; +} + +/** + * Check that the wallet cherry-picks the exchange's /keys response, and that + * a cherry-picked response -- which leaves out every denomination the wallet + * already knows about -- does not make it forget those denominations. + * + * Taking their absence for retirement would strand the coins the wallet holds + * of them, which is real money, so this is the property that matters. + */ +export async function runExchangeKeysCherrypickTest( + t: GlobalTestState, +): Promise<void> { + // Set up test environment + + const db = await setupDb(t); + + const bank = await BankService.create(t, { + allowRegistrations: true, + currency: "TESTKUDOS", + database: db.connStr, + httpPort: 8082, + }); + + const exchange = ExchangeService.create(t, { + name: "testexchange-1", + currency: "TESTKUDOS", + httpPort: 8081, + database: db.connStr, + }); + + const exchangeBankUsername = "exchange"; + const exchangeBankPassword = "mypw-password"; + const exchangePaytoUri = getTestHarnessPaytoForLabel(exchangeBankUsername); + + await exchange.addBankAccount("1", { + wireGatewayAuth: { + type: "basic", + username: exchangeBankUsername, + password: exchangeBankPassword, + }, + wireGatewayApiBaseUrl: new URL( + `accounts/${exchangeBankUsername}/taler-wire-gateway/`, + bank.corebankApiBaseUrl, + ).href, + accountPaytoUri: exchangePaytoUri, + }); + + // The wallet talks to the exchange through a proxy, so that the test can + // see the /keys requests it makes. + const proxiedExchange = new FaultInjectedExchangeService(t, exchange, 8091); + // Base URL must contain the port that the proxy is listening on, otherwise + // the wallet rejects the response as belonging to another exchange. + await exchange.modifyConfig(async (config) => { + config.setString("exchange", "base_url", "http://localhost:8091/"); + }); + + bank.setSuggestedExchange(proxiedExchange, exchangePaytoUri); + + await bank.start(); + await bank.pingUntilAvailable(); + + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: "admin", + password: "admin-password", + }, + }); + + await bankClient.registerAccountExtended({ + name: "Exchange", + password: exchangeBankPassword, + username: exchangeBankUsername, + is_taler_exchange: true, + payto_uri: exchangePaytoUri, + }); + + exchange.addOfferedCoins(defaultCoinConfig); + + await exchange.start(); + await exchange.pingUntilAvailable(); + + const keysRequests: KeysRequestRecord[] = []; + + proxiedExchange.faultProxy.addFault({ + async modifyResponse(ctx) { + const url = new URL(ctx.request.requestUrl); + if (url.pathname !== "/keys") { + return; + } + keysRequests.push({ + lastIssueDate: url.searchParams.get("last_issue_date") ?? undefined, + responseSize: ctx.responseBody?.byteLength ?? 0, + }); + }, + }); + + const { walletClient } = await createWalletDaemonWithClient(t, { + name: "wallet", + }); + + t.logStep("setup done"); + + // Withdraw digital cash into the wallet. This adds the exchange entry and + // thus downloads /keys for the first time. + + const wres = await withdrawViaBankV3(t, { + walletClient, + bankClient, + exchange: proxiedExchange, + amount: "TESTKUDOS:20", + }); + await wres.withdrawalFinishedCond; + + t.assertTrue(keysRequests.length > 0); + // Nothing is known about the exchange yet, so there is nothing to + // cherry-pick from. + for (const req of keysRequests) { + t.assertTrue(req.lastIssueDate === undefined); + } + + const balanceBefore = await walletClient.call( + WalletApiOperation.GetBalances, + {}, + ); + + const statsInitial = await walletClient.call( + WalletApiOperation.TestingGetDenomStats, + { exchangeBaseUrl: proxiedExchange.baseUrl }, + ); + console.log(`denomination stats after withdrawal: ${j2s(statsInitial)}`); + t.assertTrue(statsInitial.numKnown > 0); + + t.logStep("withdrawal done"); + + // Move both the wallet and the exchange past the withdrawal expiration of + // the current denominations, so that the exchange starts offering a second + // generation of them. The first generation stays in /keys -- it is still + // depositable for two years -- and it is what the coins in the wallet are + // of. + + await applyTimeTravelV2( + Duration.toMilliseconds(Duration.fromSpec({ days: 10 })), + { exchange, walletClient }, + ); + + // Let the wallet pick the new generation up. The scheduled update may well + // have run by itself once the wallet's clock moved, so this only makes sure + // the entry is current again, without assuming which update did it. The + // forced one downloads the whole response, which is the size to compare the + // cherry-picked one against -- the very first response is no yardstick, the + // exchange offered half as many denominations back then. + keysRequests.length = 0; + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { + exchangeBaseUrl: proxiedExchange.baseUrl, + force: true, + }); + await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, { + exchangeBaseUrl: proxiedExchange.baseUrl, + forceUpdate: true, + }); + + console.log(`/keys requests of the full update: ${j2s(keysRequests)}`); + const fullResponse = keysRequests.find((r) => r.lastIssueDate === undefined); + t.assertTrue(fullResponse !== undefined); + + const statsBefore = await walletClient.call( + WalletApiOperation.TestingGetDenomStats, + { exchangeBaseUrl: proxiedExchange.baseUrl }, + ); + console.log(`denomination stats before cherry-picking: ${j2s(statsBefore)}`); + // Both generations are known, and both are still offered. + t.assertTrue(statsBefore.numKnown > statsInitial.numKnown); + t.assertDeepEqual(statsBefore.numOffered, statsBefore.numKnown); + + t.logStep("second generation of denominations known"); + + // Move only the wallet forward, past the two hours it schedules the next + // update after. The exchange keeps the denominations the wallet knows, so + // the newest stamp_start it saw is a valid cherry-picking point. + + await walletClient.call(WalletApiOperation.TestingSetTimetravel, { + offsetMs: Duration.toMilliseconds( + Duration.fromSpec({ days: 10, hours: 3 }), + ), + }); + + keysRequests.length = 0; + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { + exchangeBaseUrl: proxiedExchange.baseUrl, + force: false, + }); + await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, { + exchangeBaseUrl: proxiedExchange.baseUrl, + forceUpdate: true, + }); + + console.log(`/keys requests of the routine update: ${j2s(keysRequests)}`); + + t.assertTrue(keysRequests.length > 0); + const cherryPicked = keysRequests[keysRequests.length - 1]; + t.assertTrue(cherryPicked.lastIssueDate !== undefined); + // The whole point of the exercise: less data on the wire. + t.assertTrue(cherryPicked.responseSize < fullResponse!.responseSize); + + t.logStep("cherry-picked update done"); + + const statsAfter = await walletClient.call( + WalletApiOperation.TestingGetDenomStats, + { exchangeBaseUrl: proxiedExchange.baseUrl }, + ); + console.log(`denomination stats after cherry-picking: ${j2s(statsAfter)}`); + + // The denominations left out of the cherry-picked response are still + // known, and still offered. + t.assertDeepEqual(statsAfter.numKnown, statsBefore.numKnown); + t.assertDeepEqual(statsAfter.numOffered, statsBefore.numOffered); + t.assertDeepEqual(statsAfter.numLost, statsBefore.numLost); + + // No coin was written off, so the balance is untouched and no denom-loss + // transaction happened. + const balanceAfter = await walletClient.call( + WalletApiOperation.GetBalances, + {}, + ); + t.assertDeepEqual(balanceAfter, balanceBefore); + + const txs = await walletClient.call(WalletApiOperation.GetTransactions, { + sort: "stable-ascending", + }); + for (const tx of txs.transactions) { + t.assertTrue(tx.type !== "denom-loss"); + } + + // The wallet can still spend and withdraw from the exchange. + const wres2 = await withdrawViaBankV3(t, { + walletClient, + bankClient, + exchange: proxiedExchange, + amount: "TESTKUDOS:10", + }); + await wres2.withdrawalFinishedCond; + + // A forced update is never cherry-picked: it is the wallet's own picture of + // the exchange that such an update exists to rebuild. + keysRequests.length = 0; + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { + exchangeBaseUrl: proxiedExchange.baseUrl, + force: true, + }); + await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, { + exchangeBaseUrl: proxiedExchange.baseUrl, + forceUpdate: true, + }); + + console.log(`/keys requests of the forced update: ${j2s(keysRequests)}`); + t.assertTrue(keysRequests.length > 0); + for (const req of keysRequests) { + t.assertTrue(req.lastIssueDate === undefined); + } +} + +runExchangeKeysCherrypickTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -65,6 +65,7 @@ import { runDonauTest } from "./test-donau.js"; import { runExchangeBaseUrlCompletionTest } from "./test-exchange-base-url-completion.js"; import { runExchangeDepositTest } from "./test-exchange-deposit.js"; import { runExchangeEphemeralTest } from "./test-exchange-ephemeral.js"; +import { runExchangeKeysCherrypickTest } from "./test-exchange-keys-cherrypick.js"; import { runExchangeKycAuthTest } from "./test-exchange-kyc-auth.js"; import { runExchangeManagementFaultTest } from "./test-exchange-management-fault.js"; import { runExchangeManagementTest } from "./test-exchange-management.js"; @@ -287,6 +288,7 @@ const allTests: TestMainFunction[] = [ runDepositMergeTest, runSimplePaymentTest, runExchangeManagementFaultTest, + runExchangeKeysCherrypickTest, runExchangeTimetravelTest, runFeeRegressionTest, runForcedSelectionTest,