From 05535fdc226f39666ed0a692871f54dea904af7b Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 17 Jun 2024 13:05:16 +0200 Subject: wallet-core,harness: new test, provide reason for exchange entry update conflicts --- packages/taler-harness/src/harness/harness.ts | 20 ++++ .../test-exchange-master-pub-change.ts | 114 +++++++++++++++++++++ .../src/integrationtests/testrunner.ts | 2 + packages/taler-util/src/errors.ts | 18 ++++ packages/taler-util/src/taler-error-codes.ts | 8 ++ packages/taler-util/src/wallet-types.ts | 2 + packages/taler-wallet-core/src/db.ts | 2 + packages/taler-wallet-core/src/exchanges.ts | 27 ++++- packages/taler-wallet-core/src/shepherd.ts | 16 ++- 9 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 packages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts (limited to 'packages') diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts index 4fc462ddf..d67ca7c48 100644 --- a/packages/taler-harness/src/harness/harness.ts +++ b/packages/taler-harness/src/harness/harness.ts @@ -1468,6 +1468,26 @@ export class ExchangeService implements ExchangeServiceInterface { await sh(this.globalState, "rm-secmod-keys", `rm ${eddsaKeydir}/*`); } + /** + * Generate a new master public key for the exchange. + */ + async regenerateMasterPub(): Promise { + const cfg = Configuration.load(this.configFilename); + const masterPrivFile = cfg + .getPath("exchange-offline", "master_priv_file") + .required(); + fs.unlinkSync(masterPrivFile); + const exchangeMasterKey = createEddsaKeyPair(); + fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv)); + cfg.setString( + "exchange", + "master_public_key", + encodeCrock(exchangeMasterKey.eddsaPub), + ); + + cfg.writeTo(this.configFilename, { excludeDefaults: true }); + } + async purgeDatabase(): Promise { await sh( this.globalState, diff --git a/packages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts b/packages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts new file mode 100644 index 000000000..a66d94b57 --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-exchange-master-pub-change.ts @@ -0,0 +1,114 @@ +/* + This file is part of GNU Taler + (C) 2020 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 + */ + +/** + * Imports. + */ +import { + ExchangeUpdateStatus, + TalerErrorCode, + j2s, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { + ExchangeService, + GlobalTestState, + setupDb, +} from "../harness/harness.js"; +import { + createSimpleTestkudosEnvironmentV3, + withdrawViaBankV3, +} from "../harness/helpers.js"; + +/** + * Test the wallet's behavior when the exchange switches to a completely + * new master public keyy. + */ +export async function runExchangeMasterPubChangeTest( + t: GlobalTestState, +): Promise { + // Set up test environment + + const { walletClient, exchange, bankClient, exchangeBankAccount } = + await createSimpleTestkudosEnvironmentV3(t); + + const wres = await withdrawViaBankV3(t, { + walletClient, + amount: "TESTKUDOS:10", + bankClient, + exchange, + }); + + await wres.withdrawalFinishedCond; + + t.logStep("withdrawal-done"); + + const exchangesListOld = await walletClient.call( + WalletApiOperation.ListExchanges, + {}, + ); + + console.log(j2s(exchangesListOld)); + + await exchange.stop(); + + // Instead of reconfiguring the old exchange, we just create a new exchange here + // that runs under the same base URL as the old exchange. + + const db2 = await setupDb(t, { + nameSuffix: "e2", + }); + const exchange2 = ExchangeService.create(t, { + name: "testexchange-2", + currency: "TESTKUDOS", + httpPort: 8081, + database: db2.connStr, + }); + + await exchange2.addBankAccount("1", exchangeBankAccount); + exchange2.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); + await exchange2.start(); + + t.logStep("exchange-restarted"); + + const err = await t.assertThrowsTalerErrorAsync(async () => { + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { + exchangeBaseUrl: exchange.baseUrl, + force: true, + }); + }); + + console.log("updateExchangeEntry err:", j2s(err)); + + const exchangesList = await walletClient.call( + WalletApiOperation.ListExchanges, + {}, + ); + + console.log(j2s(exchangesList)); + + t.assertDeepEqual( + exchangesList.exchanges[0].exchangeUpdateStatus, + ExchangeUpdateStatus.UnavailableUpdate, + ); + t.assertDeepEqual( + exchangesList.exchanges[0].unavailableReason?.code, + TalerErrorCode.WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT, + ); +} + +runExchangeMasterPubChangeTest.suites = ["wallet", "exchange"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts index 4588310b1..b329036eb 100644 --- a/packages/taler-harness/src/integrationtests/testrunner.ts +++ b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -42,6 +42,7 @@ import { runDepositTest } from "./test-deposit.js"; import { runExchangeDepositTest } from "./test-exchange-deposit.js"; import { runExchangeManagementFaultTest } from "./test-exchange-management-fault.js"; import { runExchangeManagementTest } from "./test-exchange-management.js"; +import { runExchangeMasterPubChangeTest } from "./test-exchange-master-pub-change.js"; import { runExchangePurseTest } from "./test-exchange-purse.js"; import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js"; import { runFeeRegressionTest } from "./test-fee-regression.js"; @@ -234,6 +235,7 @@ const allTests: TestMainFunction[] = [ runWithdrawalHandoverTest, runWithdrawalAmountTest, runWithdrawalFlexTest, + runExchangeMasterPubChangeTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/errors.ts b/packages/taler-util/src/errors.ts index d68177e4e..6becf7963 100644 --- a/packages/taler-util/src/errors.ts +++ b/packages/taler-util/src/errors.ts @@ -171,6 +171,9 @@ export interface DetailsMap { tosStatus: string; currentEtag: string | undefined; }; + [TalerErrorCode.WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT]: { + detail?: string; + }; } type ErrBody = Y extends keyof DetailsMap ? DetailsMap[Y] : empty; @@ -240,6 +243,21 @@ type TalerHttpErrorsDetails = { export type TalerHttpError = TalerHttpErrorsDetails[keyof TalerHttpErrorsDetails]; +/** + * Construct typed error details. + * Fills in the hint with a default based on the error code name. + */ +export function makeTalerErrorDetail( + code: C, + errBody: ErrBody, + hint?: string, +): TalerErrorDetail { + if (!hint) { + hint = getDefaultHint(code); + } + return { code, hint, ...errBody }; +} + export class TalerError extends Error { errorDetail: TalerErrorDetail & T; cause: Error | undefined; diff --git a/packages/taler-util/src/taler-error-codes.ts b/packages/taler-util/src/taler-error-codes.ts index f77357407..a1b6ccc77 100644 --- a/packages/taler-util/src/taler-error-codes.ts +++ b/packages/taler-util/src/taler-error-codes.ts @@ -4136,6 +4136,14 @@ export enum TalerErrorCode { WALLET_EXCHANGE_TOS_NOT_ACCEPTED = 7037, + /** + * An exchange entry could not be updated, as the exchange's new details conflict with the new details. + * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0). + * (A value of 0 indicates that the error is generated client-side). + */ + WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT = 7038, + + /** * We encountered a timeout with our payment backend. * Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504). diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index 82ccb4fc4..099e5c060 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -1391,6 +1391,8 @@ export interface ExchangeListItem { * to update the exchange info. */ lastUpdateErrorInfo?: OperationErrorInfo; + + unavailableReason?: TalerErrorDetail; } const codecForAuditorDenomSig = (): Codec => diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 07caa630b..7c2380e2d 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -677,6 +677,8 @@ export interface ExchangeEntryRecord { updateStatus: ExchangeEntryDbUpdateStatus; + unavailableReason?: TalerErrorDetail; + /** * If set to true, the next update to the exchange * status will request /keys with no-cache headers set. diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts index 903f79dcd..3bec30587 100644 --- a/packages/taler-wallet-core/src/exchanges.ts +++ b/packages/taler-wallet-core/src/exchanges.ts @@ -45,6 +45,7 @@ import { ExchangeListItem, ExchangeSignKeyJson, ExchangeTosStatus, + ExchangeUpdateStatus, ExchangeWireAccount, ExchangesListResponse, FeeDescription, @@ -87,6 +88,7 @@ import { hashDenomPub, j2s, makeErrorDetail, + makeTalerErrorDetail, parsePaytoUri, } from "@gnu-taler/taler-util"; import { @@ -325,7 +327,7 @@ async function makeExchangeListItem( scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); } - return { + const listItem: ExchangeListItem = { exchangeBaseUrl: r.baseUrl, masterPub: exchangeDetails?.masterPublicKey, noFees: r.noFees ?? false, @@ -346,6 +348,14 @@ async function makeExchangeListItem( url: r.baseUrl, }, }; + switch (listItem.exchangeUpdateStatus) { + case ExchangeUpdateStatus.UnavailableUpdate: + if (r.unavailableReason) { + listItem.unavailableReason = r.unavailableReason; + } + break; + } + return listItem; } export interface ExchangeWireDetails { @@ -1476,16 +1486,18 @@ export async function updateExchangeFromUrlHandler( detailsPointerChanged = true; } let detailsIncompatible = false; + let conflictHint: string | undefined = undefined; if (existingDetails) { if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) { detailsIncompatible = true; detailsPointerChanged = true; - } - if (existingDetails.currency !== keysInfo.currency) { + conflictHint = "master public key changed"; + } else if (existingDetails.currency !== keysInfo.currency) { detailsIncompatible = true; detailsPointerChanged = true; + conflictHint = "currency changed"; } - // FIXME: We need to do some consistency checks! + // FIXME: We need to do some more consistency checks! } if (detailsIncompatible) { logger.warn( @@ -1494,6 +1506,12 @@ export async function updateExchangeFromUrlHandler( // We don't support this gracefully right now. // See https://bugs.taler.net/n/8576 r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate; + r.unavailableReason = makeTalerErrorDetail( + TalerErrorCode.WALLET_EXCHANGE_ENTRY_UPDATE_CONFLICT, + { + detail: conflictHint, + }, + ); r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1; r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter); r.nextRefreshCheckStamp = timestampPreciseToDb( @@ -1506,6 +1524,7 @@ export async function updateExchangeFromUrlHandler( newExchangeState: getExchangeState(r), }; } + delete r.unavailableReason; r.updateRetryCounter = 0; const newDetails: ExchangeDetailsRecord = { auditors: keysInfo.auditors, diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts index 470f45aff..2b529fb4b 100644 --- a/packages/taler-wallet-core/src/shepherd.ts +++ b/packages/taler-wallet-core/src/shepherd.ts @@ -145,6 +145,14 @@ function taskGivesLiveness(taskId: string): boolean { } export interface TaskScheduler { + /** + * Ensure that the task scheduler is running. + * + * If it is not running, start it, with previous + * tasks loaded from the database. + * + * Returns after the scheduler is running. + */ ensureRunning(): Promise; startShepherdTask(taskId: TaskIdStr): void; stopShepherdTask(taskId: TaskIdStr): void; @@ -188,6 +196,9 @@ export class TaskSchedulerImpl implements TaskScheduler { } } + /** + * @see TaskScheduler.ensureRunning + */ async ensureRunning(): Promise { if (this.isRunning) { return; @@ -261,7 +272,7 @@ export class TaskSchedulerImpl implements TaskScheduler { const tasksIds = [...this.sheps.keys()]; logger.info(`reloading shepherd with ${tasksIds.length} tasks`); for (const taskId of tasksIds) { - await this.stopShepherdTask(taskId); + this.stopShepherdTask(taskId); } for (const taskId of tasksIds) { this.startShepherdTask(taskId); @@ -276,9 +287,10 @@ export class TaskSchedulerImpl implements TaskScheduler { return; } logger.trace( - `Waiting old task to complete the loop in cancel mode ${taskId}`, + `Waiting for old task to complete the loop in cancel mode ${taskId}`, ); await oldShep.latch; + logger.trace(`Old task ${taskId} completed in cancel mode`); } logger.trace(`Creating new shepherd for ${taskId}`); const newShep: ShepherdInfo = { -- cgit v1.2.3