From 8e716c3bff673a35e9f40cb54fca666aee0bd67b Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 28 Feb 2024 02:04:29 +0100 Subject: wallet-core: fix exchange entry deletion, test it --- .../test-exchange-management-fault.ts | 286 +++++++++++++++++++++ .../integrationtests/test-exchange-management.ts | 250 ++---------------- .../src/integrationtests/testrunner.ts | 4 +- packages/taler-wallet-cli/src/index.ts | 17 ++ packages/taler-wallet-core/src/exchanges.ts | 27 +- packages/taler-wallet-core/src/query.ts | 22 ++ 6 files changed, 362 insertions(+), 244 deletions(-) create mode 100644 packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts diff --git a/packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts b/packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts new file mode 100644 index 000000000..d3bd022ae --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts @@ -0,0 +1,286 @@ +/* + 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 { + ExchangesListResponse, + TalerCorebankApiClient, + TalerErrorCode, + URL, + j2s, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { + FaultInjectedExchangeService, + FaultInjectionResponseContext, +} from "../harness/faultInjection.js"; +import { + BankService, + ExchangeService, + GlobalTestState, + MerchantService, + WalletCli, + generateRandomPayto, + setupDb, +} from "../harness/harness.js"; + +/** + * Test if the wallet handles outdated exchange versions correctly. + */ +export async function runExchangeManagementFaultTest( + t: GlobalTestState, +): Promise { + // 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 merchant = await MerchantService.create(t, { + name: "testmerchant-1", + currency: "TESTKUDOS", + httpPort: 8083, + database: db.connStr, + }); + + const exchangeBankAccount = await bank.createExchangeAccount( + "myexchange", + "x", + ); + exchange.addBankAccount("1", exchangeBankAccount); + + const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091); + // Base URL must contain port that the proxy is listening on. + await exchange.modifyConfig(async (config) => { + config.setString("exchange", "base_url", "http://localhost:8091/"); + }); + + bank.setSuggestedExchange( + faultyExchange, + exchangeBankAccount.accountPaytoUri, + ); + + await bank.start(); + + await bank.pingUntilAvailable(); + + exchange.addOfferedCoins(defaultCoinConfig); + + await exchange.start(); + await exchange.pingUntilAvailable(); + + merchant.addExchange(exchange); + + await merchant.start(); + await merchant.pingUntilAvailable(); + + await merchant.addInstanceWithWireAccount({ + id: "default", + name: "Default Instance", + paytoUris: [generateRandomPayto("merchant-default")], + }); + + await merchant.addInstanceWithWireAccount({ + id: "minst1", + name: "minst1", + paytoUris: [generateRandomPayto("minst1")], + }); + + console.log("setup done!"); + + /* + * ========================================================================= + * Check that the exchange can be added to the wallet + * (without any faults active). + * ========================================================================= + */ + + const wallet = new WalletCli(t); + + let exchangesList: ExchangesListResponse; + + exchangesList = await wallet.client.call( + WalletApiOperation.ListExchanges, + {}, + ); + console.log("exchanges list:", j2s(exchangesList)); + t.assertTrue(exchangesList.exchanges.length === 0); + + // Try before fault is injected + await wallet.client.call(WalletApiOperation.AddExchange, { + exchangeBaseUrl: faultyExchange.baseUrl, + }); + + exchangesList = await wallet.client.call( + WalletApiOperation.ListExchanges, + {}, + ); + t.assertTrue(exchangesList.exchanges.length === 1); + + await wallet.client.call(WalletApiOperation.ListExchanges, {}); + + console.log("listing exchanges"); + + exchangesList = await wallet.client.call( + WalletApiOperation.ListExchanges, + {}, + ); + t.assertTrue(exchangesList.exchanges.length === 1); + + console.log("got list", exchangesList); + + /* + * ========================================================================= + * Check what happens if the exchange returns something totally + * bogus for /keys. + * ========================================================================= + */ + + wallet.deleteDatabase(); + + exchangesList = await wallet.client.call( + WalletApiOperation.ListExchanges, + {}, + ); + t.assertTrue(exchangesList.exchanges.length === 0); + + faultyExchange.faultProxy.addFault({ + async modifyResponse(ctx: FaultInjectionResponseContext) { + const url = new URL(ctx.request.requestUrl); + if (url.pathname === "/keys") { + const body = { + version: "whaaat", + }; + ctx.responseBody = Buffer.from(JSON.stringify(body), "utf-8"); + } + }, + }); + + const err1 = await t.assertThrowsTalerErrorAsync(async () => { + await wallet.client.call(WalletApiOperation.AddExchange, { + exchangeBaseUrl: faultyExchange.baseUrl, + }); + }); + + console.log("got error", err1); + + // Response is malformed, since it didn't even contain a version code + // in a format the wallet can understand. + t.assertTrue( + err1.errorDetail.code === TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + ); + + exchangesList = await wallet.client.call( + WalletApiOperation.ListExchanges, + {}, + ); + console.log("exchanges list", j2s(exchangesList)); + t.assertTrue(exchangesList.exchanges.length === 1); + t.assertTrue( + exchangesList.exchanges[0].lastUpdateErrorInfo?.error.code === + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + ); + + /* + * ========================================================================= + * Check what happens if the exchange returns an old, unsupported + * version for /keys + * ========================================================================= + */ + + wallet.deleteDatabase(); + faultyExchange.faultProxy.clearAllFaults(); + + faultyExchange.faultProxy.addFault({ + async modifyResponse(ctx: FaultInjectionResponseContext) { + const url = new URL(ctx.request.requestUrl); + if (url.pathname === "/keys") { + const keys = ctx.responseBody?.toString("utf-8"); + t.assertTrue(keys != null); + const keysJson = JSON.parse(keys); + keysJson["version"] = "2:0:0"; + ctx.responseBody = Buffer.from(JSON.stringify(keysJson), "utf-8"); + } + }, + }); + + const err2 = await t.assertThrowsTalerErrorAsync(async () => { + await wallet.client.call(WalletApiOperation.AddExchange, { + exchangeBaseUrl: faultyExchange.baseUrl, + }); + }); + + t.assertTrue(err2.hasErrorCode(TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE)); + + exchangesList = await wallet.client.call( + WalletApiOperation.ListExchanges, + {}, + ); + t.assertTrue(exchangesList.exchanges.length === 1); + t.assertTrue( + exchangesList.exchanges[0].lastUpdateErrorInfo?.error.code === + TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE, + ); + + /* + * ========================================================================= + * Check that the exchange version is also checked when + * the exchange is implicitly added via the suggested + * exchange of a bank-integrated withdrawal. + * ========================================================================= + */ + + // Fault from above is still active! + + // Create withdrawal operation + + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl); + + const user = await bankClient.createRandomBankUser(); + const wop = await bankClient.createWithdrawalOperation( + user.username, + "TESTKUDOS:10", + ); + + // Hand it to the wallet + + const wd = await wallet.client.call( + WalletApiOperation.GetWithdrawalDetailsForUri, + { + talerWithdrawUri: wop.taler_withdraw_uri, + }, + ); + + // Make sure the faulty exchange isn't used for the suggestion. + t.assertTrue(wd.possibleExchanges.length === 0); +} + +runExchangeManagementFaultTest.suites = ["wallet", "exchange"]; diff --git a/packages/taler-harness/src/integrationtests/test-exchange-management.ts b/packages/taler-harness/src/integrationtests/test-exchange-management.ts index 37b490d6b..8e2a8f589 100644 --- a/packages/taler-harness/src/integrationtests/test-exchange-management.ts +++ b/packages/taler-harness/src/integrationtests/test-exchange-management.ts @@ -17,28 +17,9 @@ /** * Imports. */ -import { - ExchangesListResponse, - TalerCorebankApiClient, - TalerErrorCode, - URL, - j2s, -} from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { defaultCoinConfig } from "../harness/denomStructures.js"; -import { - FaultInjectedExchangeService, - FaultInjectionResponseContext, -} from "../harness/faultInjection.js"; -import { - BankService, - ExchangeService, - GlobalTestState, - MerchantService, - WalletCli, - generateRandomPayto, - setupDb, -} from "../harness/harness.js"; +import { GlobalTestState } from "../harness/harness.js"; +import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js"; /** * Test if the wallet handles outdated exchange versions correctly. @@ -48,239 +29,52 @@ export async function runExchangeManagementTest( ): Promise { // Set up test environment - const db = await setupDb(t); + const { walletClient, bank, exchange } = + await createSimpleTestkudosEnvironmentV2(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 merchant = await MerchantService.create(t, { - name: "testmerchant-1", - currency: "TESTKUDOS", - httpPort: 8083, - database: db.connStr, - }); - - const exchangeBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); - exchange.addBankAccount("1", exchangeBankAccount); - - const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091); - // Base URL must contain port that the proxy is listening on. - await exchange.modifyConfig(async (config) => { - config.setString("exchange", "base_url", "http://localhost:8091/"); - }); - - bank.setSuggestedExchange( - faultyExchange, - exchangeBankAccount.accountPaytoUri, - ); - - await bank.start(); - - await bank.pingUntilAvailable(); - - exchange.addOfferedCoins(defaultCoinConfig); - - await exchange.start(); - await exchange.pingUntilAvailable(); - - merchant.addExchange(exchange); - - await merchant.start(); - await merchant.pingUntilAvailable(); - - await merchant.addInstanceWithWireAccount({ - id: "default", - name: "Default Instance", - paytoUris: [generateRandomPayto("merchant-default")], - }); - - await merchant.addInstanceWithWireAccount({ - id: "minst1", - name: "minst1", - paytoUris: [generateRandomPayto("minst1")], - }); - - console.log("setup done!"); - - /* - * ========================================================================= - * Check that the exchange can be added to the wallet - * (without any faults active). - * ========================================================================= - */ - - const wallet = new WalletCli(t); - - let exchangesList: ExchangesListResponse; - - exchangesList = await wallet.client.call( - WalletApiOperation.ListExchanges, - {}, - ); - console.log("exchanges list:", j2s(exchangesList)); - t.assertTrue(exchangesList.exchanges.length === 0); - - // Try before fault is injected - await wallet.client.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: faultyExchange.baseUrl, - }); - - exchangesList = await wallet.client.call( + const exchangesListResult = await walletClient.call( WalletApiOperation.ListExchanges, {}, ); - t.assertTrue(exchangesList.exchanges.length === 1); - - await wallet.client.call(WalletApiOperation.ListExchanges, {}); - console.log("listing exchanges"); - - exchangesList = await wallet.client.call( - WalletApiOperation.ListExchanges, - {}, - ); - t.assertTrue(exchangesList.exchanges.length === 1); + t.assertDeepEqual(exchangesListResult.exchanges.length, 1); - console.log("got list", exchangesList); - - /* - * ========================================================================= - * Check what happens if the exchange returns something totally - * bogus for /keys. - * ========================================================================= - */ - - wallet.deleteDatabase(); + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { + exchangeBaseUrl: exchange.baseUrl, + force: true, + }); - exchangesList = await wallet.client.call( + const exchangesListResult2 = await walletClient.call( WalletApiOperation.ListExchanges, {}, ); - t.assertTrue(exchangesList.exchanges.length === 0); - faultyExchange.faultProxy.addFault({ - async modifyResponse(ctx: FaultInjectionResponseContext) { - const url = new URL(ctx.request.requestUrl); - if (url.pathname === "/keys") { - const body = { - version: "whaaat", - }; - ctx.responseBody = Buffer.from(JSON.stringify(body), "utf-8"); - } - }, - }); + t.assertDeepEqual(exchangesListResult2.exchanges.length, 2); - const err1 = await t.assertThrowsTalerErrorAsync(async () => { - await wallet.client.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: faultyExchange.baseUrl, - }); + await walletClient.call(WalletApiOperation.DeleteExchange, { + exchangeBaseUrl: exchange.baseUrl, }); - console.log("got error", err1); - - // Response is malformed, since it didn't even contain a version code - // in a format the wallet can understand. - t.assertTrue( - err1.errorDetail.code === TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, - ); - - exchangesList = await wallet.client.call( + const exchangesListResult3 = await walletClient.call( WalletApiOperation.ListExchanges, {}, ); - console.log("exchanges list", j2s(exchangesList)); - t.assertTrue(exchangesList.exchanges.length === 1); - t.assertTrue( - exchangesList.exchanges[0].lastUpdateErrorInfo?.error.code === - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - ); - - /* - * ========================================================================= - * Check what happens if the exchange returns an old, unsupported - * version for /keys - * ========================================================================= - */ - wallet.deleteDatabase(); - faultyExchange.faultProxy.clearAllFaults(); + t.assertDeepEqual(exchangesListResult3.exchanges.length, 1); - faultyExchange.faultProxy.addFault({ - async modifyResponse(ctx: FaultInjectionResponseContext) { - const url = new URL(ctx.request.requestUrl); - if (url.pathname === "/keys") { - const keys = ctx.responseBody?.toString("utf-8"); - t.assertTrue(keys != null); - const keysJson = JSON.parse(keys); - keysJson["version"] = "2:0:0"; - ctx.responseBody = Buffer.from(JSON.stringify(keysJson), "utf-8"); - } - }, - }); + // Check for regression: Can we re-add a deleted exchange? - const err2 = await t.assertThrowsTalerErrorAsync(async () => { - await wallet.client.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: faultyExchange.baseUrl, - }); + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { + exchangeBaseUrl: exchange.baseUrl, + force: true, }); - t.assertTrue(err2.hasErrorCode(TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE)); - - exchangesList = await wallet.client.call( + const exchangesListResult4 = await walletClient.call( WalletApiOperation.ListExchanges, {}, ); - t.assertTrue(exchangesList.exchanges.length === 1); - t.assertTrue( - exchangesList.exchanges[0].lastUpdateErrorInfo?.error.code === - TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE, - ); - - /* - * ========================================================================= - * Check that the exchange version is also checked when - * the exchange is implicitly added via the suggested - * exchange of a bank-integrated withdrawal. - * ========================================================================= - */ - - // Fault from above is still active! - - // Create withdrawal operation - - const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl); - - const user = await bankClient.createRandomBankUser(); - const wop = await bankClient.createWithdrawalOperation( - user.username, - "TESTKUDOS:10", - ); - - // Hand it to the wallet - - const wd = await wallet.client.call( - WalletApiOperation.GetWithdrawalDetailsForUri, - { - talerWithdrawUri: wop.taler_withdraw_uri, - }, - ); - // Make sure the faulty exchange isn't used for the suggestion. - t.assertTrue(wd.possibleExchanges.length === 0); + t.assertDeepEqual(exchangesListResult4.exchanges.length, 2); } runExchangeManagementTest.suites = ["wallet", "exchange"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts index 1fb987802..b57d82939 100644 --- a/packages/taler-harness/src/integrationtests/testrunner.ts +++ b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -39,6 +39,7 @@ import { runCurrencyScopeTest } from "./test-currency-scope.js"; import { runDenomUnofferedTest } from "./test-denom-unoffered.js"; 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 { runExchangePurseTest } from "./test-exchange-purse.js"; import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js"; @@ -129,7 +130,7 @@ const allTests: TestMainFunction[] = [ runDenomUnofferedTest, runDepositTest, runSimplePaymentTest, - runExchangeManagementTest, + runExchangeManagementFaultTest, runExchangeTimetravelTest, runFeeRegressionTest, runForcedSelectionTest, @@ -194,6 +195,7 @@ const allTests: TestMainFunction[] = [ runWalletCliTerminationTest, runOtpTest, runWalletBalanceNotificationsTest, + runExchangeManagementTest, ]; export interface TestRunSpec { diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index b1d813e0d..ffe033b24 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -839,6 +839,23 @@ exchangesCli }); }); +exchangesCli + .subcommand("exchangesAddCmd", "delete", { + help: "Delete an exchange by base URL.", + }) + .requiredArgument("url", clk.STRING, { + help: "Base URL of the exchange.", + }) + .flag("purge", ["--purge"]) + .action(async (args) => { + await withWallet(args, async (wallet) => { + await wallet.client.call(WalletApiOperation.DeleteExchange, { + exchangeBaseUrl: args.exchangesAddCmd.url, + purge: args.exchangesAddCmd.purge, + }); + }); + }); + exchangesCli .subcommand("exchangesAcceptTosCmd", "accept-tos", { help: "Accept terms of service.", diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts index 5b0d3e823..dd4296d8a 100644 --- a/packages/taler-wallet-core/src/exchanges.ts +++ b/packages/taler-wallet-core/src/exchanges.ts @@ -2016,23 +2016,20 @@ export async function deleteExchange( return; } const res = await internalGetExchangeResources(wex, tx, exchangeBaseUrl); - if (res.hasResources) { - if (req.purge) { - const detRecs = - await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll(); - for (const r of detRecs) { - if (r.rowId == null) { - // Should never happen, as rowId is the primary key. - continue; - } - await tx.exchangeDetails.delete(r.rowId); - } - // FIXME: Also remove records related to transactions? - } else { - inUse = true; - return; + if (res.hasResources && !req.purge) { + inUse = true; + return; + } + const detRecs = + await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll(); + for (const r of detRecs) { + if (r.rowId == null) { + // Should never happen, as rowId is the primary key. + continue; } + await tx.exchangeDetails.delete(r.rowId); } + // FIXME: Also remove records related to transactions? await tx.exchanges.delete(exchangeBaseUrl); }, ); diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts index 994a6a96d..d128805d2 100644 --- a/packages/taler-wallet-core/src/query.ts +++ b/packages/taler-wallet-core/src/query.ts @@ -349,6 +349,10 @@ interface IndexReadOnlyAccessor { query?: IDBKeyRange | IDBValidKey, count?: number, ): Promise; + getAllKeys( + query?: IDBKeyRange | IDBValidKey, + count?: number, + ): Promise; count(query?: IDBValidKey): Promise; } @@ -363,6 +367,10 @@ interface IndexReadWriteAccessor { query?: IDBKeyRange | IDBValidKey, count?: number, ): Promise; + getAllKeys( + query?: IDBKeyRange | IDBValidKey, + count?: number, + ): Promise; count(query?: IDBValidKey): Promise; } @@ -642,6 +650,13 @@ function makeReadContext( .getAll(query, count); return requestToPromise(req); }, + getAllKeys(query, count) { + const req = tx + .objectStore(storeName) + .index(indexName) + .getAllKeys(query, count); + return requestToPromise(req); + }, count(query) { const req = tx.objectStore(storeName).index(indexName).count(query); return requestToPromise(req); @@ -699,6 +714,13 @@ function makeWriteContext( .getAll(query, count); return requestToPromise(req); }, + getAllKeys(query, count) { + const req = tx + .objectStore(storeName) + .index(indexName) + .getAllKeys(query, count); + return requestToPromise(req); + }, count(query) { const req = tx.objectStore(storeName).index(indexName).count(query); return requestToPromise(req); -- cgit v1.2.3