diff --git a/packages/taler-integrationtests/src/faultInjection.ts b/packages/taler-integrationtests/src/faultInjection.ts index 26bfeee53..46ab1c5fe 100644 --- a/packages/taler-integrationtests/src/faultInjection.ts +++ b/packages/taler-integrationtests/src/faultInjection.ts @@ -182,7 +182,7 @@ export class FaultProxy { this.currentFaultSpecs.push(f); } - clearFault() { + clearAllFaults() { this.currentFaultSpecs = []; } } diff --git a/packages/taler-integrationtests/src/harness.ts b/packages/taler-integrationtests/src/harness.ts index e8a0941d2..0cf769163 100644 --- a/packages/taler-integrationtests/src/harness.ts +++ b/packages/taler-integrationtests/src/harness.ts @@ -44,6 +44,12 @@ import { codecForPreparePayResultPaymentPossible, codecForPreparePayResult, OperationFailedError, + AddExchangeRequest, + ExchangesListRespose, + codecForExchangesListResponse, + GetWithdrawalDetailsForUriRequest, + WithdrawUriInfoResponse, + codecForWithdrawUriInfoResponse, } from "taler-wallet-core"; import { URL } from "url"; import axios from "axios"; @@ -60,6 +66,7 @@ import { eddsaGetPublic, createEddsaKeyPair, } from "taler-wallet-core/lib/crypto/talerCrypto"; +import { WithdrawalDetails } from "taler-wallet-core/lib/types/transactions"; const exec = util.promisify(require("child_process").exec); @@ -244,6 +251,22 @@ export class GlobalTestState { process.on("uncaughtException", () => this.shutdownSync()); } + async assertThrowsOperationErrorAsync( + block: () => Promise, + ): Promise { + try { + await block(); + } catch (e) { + if (e instanceof OperationFailedError) { + return e; + } + throw Error(`expected OperationFailedError to be thrown, but got ${e}`); + } + throw Error( + `expected OperationFailedError to be thrown, but block finished without throwing`, + ); + } + assertTrue(b: boolean): asserts b { if (!b) { throw Error("test assertion failed"); @@ -488,7 +511,7 @@ export class BankService { return new BankService(gc, bc, cfgFilename); } - setSuggestedExchange(e: ExchangeService, exchangePayto: string) { + setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) { const config = Configuration.load(this.configFile); config.setString("bank", "suggested_exchange", e.baseUrl); config.setString("bank", "suggested_exchange_payto", exchangePayto); @@ -1153,4 +1176,30 @@ export class WalletCli { } throw new OperationFailedError(resp.error); } + + async addExchange(req: AddExchangeRequest): Promise { + const resp = await this.apiRequest("addExchange", req); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + + async listExchanges(): Promise { + const resp = await this.apiRequest("listExchanges", {}); + if (resp.type === "response") { + return codecForExchangesListResponse().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } + + async getWithdrawalDetailsForUri( + req: GetWithdrawalDetailsForUriRequest, + ): Promise { + const resp = await this.apiRequest("getWithdrawalDetailsForUri", req); + if (resp.type === "response") { + return codecForWithdrawUriInfoResponse().decode(resp.result); + } + throw new OperationFailedError(resp.error); + } } diff --git a/packages/taler-integrationtests/src/test-exchange-management.ts b/packages/taler-integrationtests/src/test-exchange-management.ts new file mode 100644 index 000000000..990a6fa4e --- /dev/null +++ b/packages/taler-integrationtests/src/test-exchange-management.ts @@ -0,0 +1,232 @@ +/* + 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 { + runTest, + GlobalTestState, + WalletCli, + setupDb, + BankService, + ExchangeService, + MerchantService, + defaultCoinConfig, +} from "./harness"; +import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers"; +import { + PreparePayResultType, + ExchangesListRespose, + URL, +} from "taler-wallet-core"; +import { + FaultInjectedExchangeService, + FaultInjectionResponseContext, +} from "./faultInjection"; + +/** + * Test if the wallet handles outdated exchange versions correct.y + */ +runTest(async (t: GlobalTestState) => { + // 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); + + 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.addInstance({ + id: "minst1", + name: "minst1", + paytoUris: ["payto://x-taler-bank/minst1"], + }); + + await merchant.addInstance({ + id: "default", + name: "Default Instance", + paytoUris: [`payto://x-taler-bank/merchant-default`], + }); + + 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: ExchangesListRespose; + + exchangesList = await wallet.listExchanges(); + t.assertTrue(exchangesList.exchanges.length === 0); + + // Try before fault is injected + await wallet.addExchange({ + exchangeBaseUrl: faultyExchange.baseUrl, + }); + + exchangesList = await wallet.listExchanges(); + t.assertTrue(exchangesList.exchanges.length === 1); + + await wallet.addExchange({ + exchangeBaseUrl: faultyExchange.baseUrl, + }); + + console.log("listing exchanges"); + + exchangesList = await wallet.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.listExchanges(); + t.assertTrue(exchangesList.exchanges.length === 0); + + faultyExchange.faultProxy.addFault({ + 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"); + } + }, + }); + + await t.assertThrowsOperationErrorAsync(async () => { + await wallet.addExchange({ + exchangeBaseUrl: faultyExchange.baseUrl, + }); + }); + + exchangesList = await wallet.listExchanges(); + t.assertTrue(exchangesList.exchanges.length === 0); + + /* + * ========================================================================= + * Check what happens if the exchange returns an old, unsupported + * version for /keys + * ========================================================================= + */ + + wallet.deleteDatabase(); + faultyExchange.faultProxy.clearAllFaults(); + + faultyExchange.faultProxy.addFault({ + 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"); + } + }, + }); + + await t.assertThrowsOperationErrorAsync(async () => { + await wallet.addExchange({ + exchangeBaseUrl: faultyExchange.baseUrl, + }); + }); + + exchangesList = await wallet.listExchanges(); + t.assertTrue(exchangesList.exchanges.length === 0); + + /* + * ========================================================================= + * 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 user = await bank.createRandomBankUser(); + const wop = await bank.createWithdrawalOperation(user, "TESTKUDOS:10"); + + // Hand it to the wallet + + const wd = await wallet.getWithdrawalDetailsForUri({ + talerWithdrawUri: wop.taler_withdraw_uri, + }); + + // Make sure the faulty exchange isn't used for the suggestion. + t.assertTrue(wd.possibleExchanges.length === 0); +}); diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts index 954798e46..784c9d63b 100644 --- a/packages/taler-wallet-core/src/index.ts +++ b/packages/taler-wallet-core/src/index.ts @@ -75,3 +75,5 @@ export * from "./crypto/workers/nodeThreadWorker"; export * from "./types/notifications"; export { Configuration } from "./util/talerconfig"; + +export { URL } from "./util/url"; \ No newline at end of file diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index 8967173ca..a7771f6d2 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -88,7 +88,7 @@ async function setExchangeError( baseUrl: string, err: OperationErrorDetails, ): Promise { - console.log(`last error for exchange ${baseUrl}:`, err); + logger.warn(`last error for exchange ${baseUrl}:`, err); const mut = (exchange: ExchangeRecord): ExchangeRecord => { exchange.lastError = err; return exchange; diff --git a/packages/taler-wallet-core/src/types/talerTypes.ts b/packages/taler-wallet-core/src/types/talerTypes.ts index bb0118a78..99f44ea25 100644 --- a/packages/taler-wallet-core/src/types/talerTypes.ts +++ b/packages/taler-wallet-core/src/types/talerTypes.ts @@ -47,7 +47,7 @@ import { Duration, codecForDuration, } from "../util/time"; -import { ExchangeListItem } from "./walletTypes"; +import { ExchangeListItem, codecForExchangeListItem } from "./walletTypes"; import { codecForAmountString } from "../util/amounts"; /** @@ -940,6 +940,13 @@ export interface WithdrawUriInfoResponse { possibleExchanges: ExchangeListItem[]; } +export const codecForWithdrawUriInfoResponse = (): Codec => + buildCodecForObject() + .property("amount", codecForAmountString()) + .property("defaultExchangeBaseUrl", codecOptional(codecForString())) + .property("possibleExchanges", codecForList(codecForExchangeListItem())) + .build("WithdrawUriInfoResponse"); + /** * Response body for the following endpoint: * diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts b/packages/taler-wallet-core/src/types/walletTypes.ts index ec57e7d2a..6f6340520 100644 --- a/packages/taler-wallet-core/src/types/walletTypes.ts +++ b/packages/taler-wallet-core/src/types/walletTypes.ts @@ -554,6 +554,18 @@ export interface ExchangeListItem { paytoUris: string[]; } +export const codecForExchangeListItem = (): Codec => + buildCodecForObject() + .property("currency", codecForString()) + .property("exchangeBaseUrl", codecForString()) + .property("paytoUris", codecForList(codecForString())) + .build("ExchangeListItem"); + +export const codecForExchangesListResponse = (): Codec => + buildCodecForObject() + .property("exchanges", codecForList(codecForExchangeListItem())) + .build("ExchangesListRespose"); + export interface AcceptManualWithdrawalResult { /** * Payto URIs that can be used to fund the withdrawal.