diff --git a/packages/taler-integrationtests/src/harness.ts b/packages/taler-integrationtests/src/harness.ts index 13487fb08..18a88d260 100644 --- a/packages/taler-integrationtests/src/harness.ts +++ b/packages/taler-integrationtests/src/harness.ts @@ -46,6 +46,7 @@ import { PostOrderRequest, PostOrderResponse, } from "./merchantApiTypes"; +import { EddsaKeyPair } from "taler-wallet-core/lib/crypto/talerCrypto"; const exec = util.promisify(require("child_process").exec); @@ -77,7 +78,6 @@ export async function sh( shell: true, }); proc.stdout.on("data", (x) => { - console.log("child process got data chunk"); if (x instanceof Buffer) { stdoutChunks.push(x); } else { @@ -363,8 +363,6 @@ export interface BankConfig { currency: string; httpPort: number; database: string; - suggestedExchange: string | undefined; - suggestedExchangePayto: string | undefined; allowRegistrations: boolean; } @@ -397,8 +395,48 @@ function setCoin(config: Configuration, c: CoinConfig) { config.setString(s, "rsa_keysize", `${c.rsaKeySize}`); } +async function pingProc( + proc: ProcessWrapper | undefined, + url: string, + serviceName: string, +): Promise { + if (!proc || proc.proc.exitCode !== null) { + throw Error(`service process ${serviceName} not started, can't ping`); + } + while (true) { + try { + console.log(`pinging ${serviceName}`); + const resp = await axios.get(url); + console.log(`service ${serviceName} available`); + return; + } catch (e) { + console.log(`service ${serviceName} not ready:`, e.toString()); + await delay(1000); + } + if (!proc || proc.proc.exitCode !== null) { + throw Error(`service process ${serviceName} stopped unexpectedly`); + } + } +} + export class BankService { proc: ProcessWrapper | undefined; + + static fromExistingConfig(gc: GlobalTestState): BankService { + const cfgFilename = gc.testDir + "/bank.conf"; + console.log("reading bank config from", cfgFilename); + const config = Configuration.load(cfgFilename); + const bc: BankConfig = { + allowRegistrations: config + .getYesNo("bank", "allow_registrations") + .required(), + currency: config.getString("taler", "currency").required(), + database: config.getString("bank", "database").required(), + httpPort: config.getNumber("bank", "http_port").required(), + }; + return new BankService(gc, bc, cfgFilename); + } + static async create( gc: GlobalTestState, bc: BankConfig, @@ -414,21 +452,17 @@ export class BankService { "allow_registrations", bc.allowRegistrations ? "yes" : "no", ); - if (bc.suggestedExchange) { - config.setString("bank", "suggested_exchange", bc.suggestedExchange); - } - if (bc.suggestedExchangePayto) { - config.setString( - "bank", - "suggested_exchange_payto", - bc.suggestedExchangePayto, - ); - } const cfgFilename = gc.testDir + "/bank.conf"; config.write(cfgFilename); return new BankService(gc, bc, cfgFilename); } + setSuggestedExchange(e: ExchangeService, exchangePayto: string) { + const config = Configuration.load(this.configFile); + config.setString("bank", "suggested_exchange", e.baseUrl); + config.setString("bank", "suggested_exchange_payto", exchangePayto); + } + get port() { return this.bankConfig.httpPort; } @@ -449,16 +483,7 @@ export class BankService { async pingUntilAvailable(): Promise { const url = `http://localhost:${this.bankConfig.httpPort}/config`; - while (true) { - try { - console.log("pinging bank"); - const resp = await axios.get(url); - return; - } catch (e) { - console.log("bank not ready:", e.toString()); - await delay(1000); - } - } + await pingProc(this.proc, url, "bank"); } async createAccount(username: string, password: string): Promise { @@ -546,7 +571,6 @@ export interface ExchangeConfig { roundUnit?: string; httpPort: number; database: string; - coinConfig?: ((curr: string) => CoinConfig)[]; } export interface ExchangeServiceInterface { @@ -557,6 +581,27 @@ export interface ExchangeServiceInterface { } export class ExchangeService implements ExchangeServiceInterface { + static fromExistingConfig(gc: GlobalTestState, exchangeName: string) { + const cfgFilename = gc.testDir + `/exchange-${exchangeName}.conf`; + const config = Configuration.load(cfgFilename); + const ec: ExchangeConfig = { + currency: config.getString("taler", "currency").required(), + database: config.getString("exchangedb-postgres", "config").required(), + httpPort: config.getNumber("exchange", "port").required(), + name: exchangeName, + roundUnit: config.getString("taler", "currency_round_unit").required(), + }; + const privFile = config + .getPath("exchange", "master_priv_file") + .required(); + const eddsaPriv = fs.readFileSync(privFile); + const keyPair: EddsaKeyPair = { + eddsaPriv, + eddsaPub: talerCrypto.eddsaGetPublic(eddsaPriv), + }; + return new ExchangeService(gc, ec, cfgFilename, keyPair); + } + static create(gc: GlobalTestState, e: ExchangeConfig) { const config = new Configuration(); config.setString("taler", "currency", e.currency); @@ -586,7 +631,6 @@ export class ExchangeService implements ExchangeServiceInterface { ); config.setString("exchange", "serve", "tcp"); config.setString("exchange", "port", `${e.httpPort}`); - config.setString("exchange", "port", `${e.httpPort}`); config.setString("exchange", "signkey_duration", "4 weeks"); config.setString("exchange", "legal_duraction", "2 years"); config.setString("exchange", "lookahead_sign", "32 weeks 1 day"); @@ -607,10 +651,6 @@ export class ExchangeService implements ExchangeServiceInterface { config.setString("exchangedb-postgres", "config", e.database); - const coinConfig = e.coinConfig ?? defaultCoinConfig; - - coinConfig.forEach((cc) => setCoin(config, cc(e.currency))); - const exchangeMasterKey = talerCrypto.createEddsaKeyPair(); config.setString( @@ -632,6 +672,14 @@ export class ExchangeService implements ExchangeServiceInterface { return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey); } + addOfferedCoins(offeredCoins: ((curr: string) => CoinConfig)[]) { + const config = Configuration.load(this.configFilename); + offeredCoins.forEach((cc) => + setCoin(config, cc(this.exchangeConfig.currency)), + ); + config.write(this.configFilename); + } + get masterPub() { return talerCrypto.encodeCrock(this.keyPair.eddsaPub); } @@ -713,16 +761,7 @@ export class ExchangeService implements ExchangeServiceInterface { async pingUntilAvailable(): Promise { const url = `http://localhost:${this.exchangeConfig.httpPort}/keys`; - while (true) { - try { - console.log("pinging exchange"); - const resp = await axios.get(url); - return; - } catch (e) { - console.log("exchange not ready:", e.toString()); - await delay(1000); - } - } + await pingProc(this.exchangeHttpProc, url, `exchange (${this.name})`); } } @@ -734,6 +773,18 @@ export interface MerchantConfig { } export class MerchantService { + static fromExistingConfig(gc: GlobalTestState, name: string) { + const cfgFilename = gc.testDir + `/merchant-${name}.conf`; + const config = Configuration.load(cfgFilename); + const mc: MerchantConfig = { + currency: config.getString("taler", "currency").required(), + database: config.getString("merchantdb-postgres", "config").required(), + httpPort: config.getNumber("merchant", "port").required(), + name, + }; + return new MerchantService(gc, mc, cfgFilename); + } + proc: ProcessWrapper | undefined; constructor( @@ -844,16 +895,7 @@ export class MerchantService { async pingUntilAvailable(): Promise { const url = `http://localhost:${this.merchantConfig.httpPort}/config`; - while (true) { - try { - console.log("pinging merchant"); - const resp = await axios.get(url); - return; - } catch (e) { - console.log("merchant not ready", e.toString()); - await delay(1000); - } - } + await pingProc(this.proc, url, `merchant (${this.merchantConfig.name})`); } } @@ -903,16 +945,13 @@ function updateCurrentSymlink(testDir: string): void { } } -export function runTest(testMain: (gc: GlobalTestState) => Promise) { +export function runTestWithState( + gc: GlobalTestState, + testMain: (t: GlobalTestState) => Promise, +) { const main = async () => { - let gc: GlobalTestState | undefined; let ret = 0; try { - gc = new GlobalTestState({ - testDir: fs.mkdtempSync( - path.join(os.tmpdir(), "taler-integrationtest-"), - ), - }); updateCurrentSymlink(gc.testDir); console.log("running test in directory", gc.testDir); await testMain(gc); @@ -936,6 +975,15 @@ export function runTest(testMain: (gc: GlobalTestState) => Promise) { main(); } +export function runTest( + testMain: (gc: GlobalTestState) => Promise, +): void { + const gc = new GlobalTestState({ + testDir: fs.mkdtempSync(path.join(os.tmpdir(), "taler-integrationtest-")), + }); + runTestWithState(gc, testMain); +} + function shellWrap(s: string) { return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"; } diff --git a/packages/taler-integrationtests/src/helpers.ts b/packages/taler-integrationtests/src/helpers.ts index 01362370c..9afb66428 100644 --- a/packages/taler-integrationtests/src/helpers.ts +++ b/packages/taler-integrationtests/src/helpers.ts @@ -31,6 +31,7 @@ import { MerchantService, setupDb, BankService, + defaultCoinConfig, } from "./harness"; import { AmountString } from "taler-wallet-core/lib/types/talerTypes"; @@ -56,14 +57,8 @@ export async function createSimpleTestkudosEnvironment( currency: "TESTKUDOS", database: db.connStr, httpPort: 8082, - suggestedExchange: "http://localhost:8081/", - suggestedExchangePayto: "payto://x-taler-bank/MyExchange", }); - await bank.start(); - - await bank.pingUntilAvailable(); - const exchange = ExchangeService.create(t, { name: "testexchange-1", currency: "TESTKUDOS", @@ -71,11 +66,6 @@ export async function createSimpleTestkudosEnvironment( database: db.connStr, }); - await exchange.setupTestBankAccount(bank, "1", "MyExchange", "x"); - - await exchange.start(); - await exchange.pingUntilAvailable(); - const merchant = await MerchantService.create(t, { name: "testmerchant-1", currency: "TESTKUDOS", @@ -83,6 +73,18 @@ export async function createSimpleTestkudosEnvironment( database: db.connStr, }); + bank.setSuggestedExchange(exchange, "payto://x-taler-bank/MyExchange"); + + await bank.start(); + + await bank.pingUntilAvailable(); + + await exchange.setupTestBankAccount(bank, "1", "MyExchange", "x"); + exchange.addOfferedCoins(defaultCoinConfig); + + await exchange.start(); + await exchange.pingUntilAvailable(); + merchant.addExchange(exchange); await merchant.start(); diff --git a/packages/taler-integrationtests/src/scenario-rerun-payment-multiple.ts b/packages/taler-integrationtests/src/scenario-rerun-payment-multiple.ts new file mode 100644 index 000000000..967b53910 --- /dev/null +++ b/packages/taler-integrationtests/src/scenario-rerun-payment-multiple.ts @@ -0,0 +1,109 @@ +/* + 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 { + GlobalTestState, + BankService, + ExchangeService, + MerchantService, + WalletCli, + runTestWithState, +} from "./harness"; +import { withdrawViaBank } from "./helpers"; +import fs from "fs"; + +let existingTestDir = + process.env["TALER_TEST_OLD_DIR"] ?? "/tmp/taler-integrationtest-current"; + +if (!fs.existsSync(existingTestDir)) { + throw Error("old test dir not found"); +} + +existingTestDir = fs.realpathSync(existingTestDir); + +const prevT = new GlobalTestState({ + testDir: existingTestDir, +}); + +/** + * Run test. + */ +runTestWithState(prevT, async (t: GlobalTestState) => { + // Set up test environment + + const bank = BankService.fromExistingConfig(t); + const exchange = ExchangeService.fromExistingConfig(t, "testexchange-1"); + const merchant = MerchantService.fromExistingConfig(t, "testmerchant-1"); + + await bank.start(); + await exchange.start(); + await merchant.start(); + await Promise.all([ + bank.pingUntilAvailable(), + merchant.pingUntilAvailable(), + exchange.pingUntilAvailable(), + ]); + + const wallet = new WalletCli(t); + + // Withdraw digital cash into the wallet. + + await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:100" }); + + // Set up order. + + const orderResp = await merchant.createOrder("default", { + order: { + summary: "Buy me!", + amount: "TESTKUDOS:80", + fulfillment_url: "taler://fulfillment-success/thx", + }, + }); + + let orderStatus = await merchant.queryPrivateOrderStatus( + "default", + orderResp.order_id, + ); + + t.assertTrue(orderStatus.order_status === "unpaid"); + + // Make wallet pay for the order + + const r1 = await wallet.apiRequest("preparePay", { + talerPayUri: orderStatus.taler_pay_uri, + }); + t.assertTrue(r1.type === "response"); + + const r2 = await wallet.apiRequest("confirmPay", { + // FIXME: should be validated, don't cast! + proposalId: (r1.result as any).proposalId, + }); + t.assertTrue(r2.type === "response"); + + // Check if payment was successful. + + orderStatus = await merchant.queryPrivateOrderStatus( + "default", + orderResp.order_id, + ); + + t.assertTrue(orderStatus.order_status === "paid"); + + await t.shutdown(); +}); diff --git a/packages/taler-integrationtests/src/test-payment-fault.ts b/packages/taler-integrationtests/src/test-payment-fault.ts index 2e0448880..f0b17a7fc 100644 --- a/packages/taler-integrationtests/src/test-payment-fault.ts +++ b/packages/taler-integrationtests/src/test-payment-fault.ts @@ -29,6 +29,7 @@ import { setupDb, BankService, WalletCli, + defaultCoinConfig, } from "./harness"; import { FaultInjectedExchangeService, FaultInjectionRequestContext, FaultInjectionResponseContext } from "./faultInjection"; import { CoreApiResponse } from "taler-wallet-core/lib/walletCoreApiHandler"; @@ -46,14 +47,8 @@ runTest(async (t: GlobalTestState) => { currency: "TESTKUDOS", database: db.connStr, httpPort: 8082, - suggestedExchange: "http://localhost:8091/", - suggestedExchangePayto: "payto://x-taler-bank/MyExchange", }); - await bank.start(); - - await bank.pingUntilAvailable(); - const exchange = ExchangeService.create(t, { name: "testexchange-1", currency: "TESTKUDOS", @@ -61,7 +56,14 @@ runTest(async (t: GlobalTestState) => { database: db.connStr, }); + bank.setSuggestedExchange(exchange, "payto://x-taler-bank/MyExchange"); + + await bank.start(); + + await bank.pingUntilAvailable(); + await exchange.setupTestBankAccount(bank, "1", "MyExchange", "x"); + exchange.addOfferedCoins(defaultCoinConfig); await exchange.start(); await exchange.pingUntilAvailable(); diff --git a/packages/taler-integrationtests/src/test-payment-multiple.ts b/packages/taler-integrationtests/src/test-payment-multiple.ts index 2914b7181..84aab4c81 100644 --- a/packages/taler-integrationtests/src/test-payment-multiple.ts +++ b/packages/taler-integrationtests/src/test-payment-multiple.ts @@ -28,16 +28,13 @@ import { coin_ct10, coin_u1, } from "./harness"; -import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers"; - -/** - * Run test. - * - * This test uses a very sub-optimal denomination structure. - */ -runTest(async (t: GlobalTestState) => { - // Set up test environment +import { withdrawViaBank } from "./helpers"; +async function setupTest(t: GlobalTestState): Promise<{ + merchant: MerchantService, + exchange: ExchangeService, + bank: BankService, +}> { const db = await setupDb(t); const bank = await BankService.create(t, { @@ -45,22 +42,23 @@ runTest(async (t: GlobalTestState) => { currency: "TESTKUDOS", database: db.connStr, httpPort: 8082, - suggestedExchange: "http://localhost:8081/", - suggestedExchangePayto: "payto://x-taler-bank/MyExchange", }); - await bank.start(); - - await bank.pingUntilAvailable(); - const exchange = ExchangeService.create(t, { name: "testexchange-1", currency: "TESTKUDOS", httpPort: 8081, database: db.connStr, - coinConfig: [coin_ct10, coin_u1], }); + exchange.addOfferedCoins([coin_ct10, coin_u1]); + + bank.setSuggestedExchange(exchange, "payto://x-taler-bank/MyExchange"); + + await bank.start(); + + await bank.pingUntilAvailable(); + await exchange.setupTestBankAccount(bank, "1", "MyExchange", "x"); await exchange.start(); @@ -92,6 +90,23 @@ runTest(async (t: GlobalTestState) => { console.log("setup done!"); + return { + merchant, + bank, + exchange, + } +} + +/** + * Run test. + * + * This test uses a very sub-optimal denomination structure. + */ +runTest(async (t: GlobalTestState) => { + // Set up test environment + + const { merchant, bank, exchange } = await setupTest(t); + const wallet = new WalletCli(t); // Withdraw digital cash into the wallet. diff --git a/packages/taler-wallet-core/src/util/talerconfig.ts b/packages/taler-wallet-core/src/util/talerconfig.ts index 8c740e1e2..e9a67287c 100644 --- a/packages/taler-wallet-core/src/util/talerconfig.ts +++ b/packages/taler-wallet-core/src/util/talerconfig.ts @@ -26,7 +26,6 @@ import { AmountJson } from "./amounts"; import * as Amounts from "./amounts"; import fs from "fs"; -import { acceptExchangeTermsOfService } from "../operations/exchanges"; export class ConfigError extends Error { constructor(message: string) { @@ -56,6 +55,26 @@ export class ConfigValue { } return this.converter(this.val); } + + orUndefined(): T | undefined { + if (this.val !== undefined) { + return this.converter(this.val); + } else { + return undefined; + } + } + + orDefault(v: T): T | undefined { + if (this.val !== undefined) { + return this.converter(this.val); + } else { + return v; + } + } + + isDefined(): boolean { + return this.val !== undefined; + } } /** @@ -197,7 +216,7 @@ export class Configuration { getString(section: string, option: string): ConfigValue { const secNorm = section.toUpperCase(); const optNorm = option.toUpperCase(); - const val = (this.sectionMap[section] ?? {})[optNorm]; + const val = (this.sectionMap[secNorm] ?? {})[optNorm]; return new ConfigValue(secNorm, optNorm, val, (x) => x); } @@ -210,6 +229,36 @@ export class Configuration { ); } + getYesNo(section: string, option: string): ConfigValue { + const secNorm = section.toUpperCase(); + const optNorm = option.toUpperCase(); + const val = (this.sectionMap[secNorm] ?? {})[optNorm]; + const convert = (x: string): boolean => { + x = x.toLowerCase(); + if (x === "yes") { + return true; + } else if (x === "no") { + return false; + } + throw Error(`invalid config value for [${secNorm}]/${optNorm}, expected yes/no`); + }; + return new ConfigValue(secNorm, optNorm, val, convert); + } + + getNumber(section: string, option: string): ConfigValue { + const secNorm = section.toUpperCase(); + const optNorm = option.toUpperCase(); + const val = (this.sectionMap[secNorm] ?? {})[optNorm]; + const convert = (x: string): number => { + try { + return Number.parseInt(x, 10); + } catch (e) { + throw Error(`invalid config value for [${secNorm}]/${optNorm}, expected number`); + } + }; + return new ConfigValue(secNorm, optNorm, val, convert); + } + lookupVariable(x: string, depth: number = 0): string | undefined { // We loop up options in PATHS in upper case, as option names // are case insensitive