diff --git a/.gitignore b/.gitignore index 057809847..0dff3b85a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,8 @@ tsconfig.tsbuildinfo # GNU-style build system /configure -build-system/config.mk +/build-system/config.mk +/Makefile # Editor files \#*\# @@ -20,3 +21,4 @@ build-scripts/ # Git worktree of pre-built wallet files prebuilt/ + diff --git a/packages/taler-integrationtests/package.json b/packages/taler-integrationtests/package.json new file mode 100644 index 000000000..713852370 --- /dev/null +++ b/packages/taler-integrationtests/package.json @@ -0,0 +1,43 @@ +{ + "name": "taler-integrationtests", + "version": "0.0.1", + "description": "Integration tests and fault injection for GNU Taler components", + "main": "index.js", + "scripts": { + "compile": "tsc", + "test": "tsc && ava" + }, + "author": "Florian Dold ", + "license": "AGPL-3.0-or-later", + "devDependencies": { + "@ava/typescript": "^1.1.1", + "ava": "^3.11.1", + "esm": "^3.2.25", + "source-map-support": "^0.5.19", + "ts-node": "^8.10.2" + }, + "dependencies": { + "axios": "^0.19.2", + "taler-wallet-core": "workspace:*", + "tslib": "^2.0.0", + "typescript": "^3.9.7" + }, + "ava": { + "require": [ + "esm" + ], + "files": [ + "src/**/test-*" + ], + "typescript": { + "extensions": [ + "js", + "ts", + "tsx" + ], + "rewritePaths": { + "src/": "lib/" + } + } + } +} diff --git a/packages/taler-integrationtests/src/faultInjection.ts b/packages/taler-integrationtests/src/faultInjection.ts new file mode 100644 index 000000000..a9c249fd0 --- /dev/null +++ b/packages/taler-integrationtests/src/faultInjection.ts @@ -0,0 +1,222 @@ +/* + 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 + */ + +/** + * Fault injection proxy. + * + * @author Florian Dold + */ + +/** + * Imports + */ +import * as http from "http"; +import { URL } from "url"; +import { + GlobalTestState, + ExchangeService, + BankService, + ExchangeServiceInterface, +} from "./harness"; + +export interface FaultProxyConfig { + inboundPort: number; + targetPort: number; +} + +/** + * Fault injection context. Modified by fault injection functions. + */ +export interface FaultInjectionRequestContext { + requestUrl: string; + method: string; + requestHeaders: Record; + requestBody?: Buffer; + dropRequest: boolean; +} + +export interface FaultInjectionResponseContext { + request: FaultInjectionRequestContext; + statusCode: number; + responseHeaders: Record; + responseBody: Buffer | undefined; + dropResponse: boolean; +} + +export interface FaultSpec { + modifyRequest?: (ctx: FaultInjectionRequestContext) => void; + modifyResponse?: (ctx: FaultInjectionResponseContext) => void; +} + +export class FaultProxy { + constructor( + private globalTestState: GlobalTestState, + private faultProxyConfig: FaultProxyConfig, + ) {} + + private currentFaultSpecs: FaultSpec[] = []; + + start() { + const server = http.createServer((req, res) => { + const requestChunks: Buffer[] = []; + const requestUrl = `http://locahost:${this.faultProxyConfig.inboundPort}${req.url}`; + console.log("request for", new URL(requestUrl)); + req.on("data", (chunk) => { + requestChunks.push(chunk); + }); + req.on("end", () => { + console.log("end of data"); + let requestBuffer: Buffer | undefined; + if (requestChunks.length > 0) { + requestBuffer = Buffer.concat(requestChunks); + } + console.log("full request body", requestBuffer); + + const faultReqContext: FaultInjectionRequestContext = { + dropRequest: false, + method: req.method!!, + requestHeaders: req.headers, + requestUrl, + requestBody: requestBuffer, + }; + + for (const faultSpec of this.currentFaultSpecs) { + if (faultSpec.modifyRequest) { + faultSpec.modifyRequest(faultReqContext); + } + } + + if (faultReqContext.dropRequest) { + res.destroy(); + return; + } + + const faultedUrl = new URL(faultReqContext.requestUrl); + + const proxyRequest = http.request({ + method: faultReqContext.method, + host: "localhost", + port: this.faultProxyConfig.targetPort, + path: faultedUrl.pathname + faultedUrl.search, + headers: faultReqContext.requestHeaders, + }); + + console.log( + `proxying request to target path '${ + faultedUrl.pathname + faultedUrl.search + }'`, + ); + + if (faultReqContext.requestBody) { + proxyRequest.write(faultReqContext.requestBody); + } + proxyRequest.end(); + proxyRequest.on("response", (proxyResp) => { + console.log("gotten response from target", proxyResp.statusCode); + const respChunks: Buffer[] = []; + proxyResp.on("data", (proxyRespData) => { + respChunks.push(proxyRespData); + }); + proxyResp.on("end", () => { + console.log("end of target response"); + let responseBuffer: Buffer | undefined; + if (respChunks.length > 0) { + responseBuffer = Buffer.concat(respChunks); + } + const faultRespContext: FaultInjectionResponseContext = { + request: faultReqContext, + dropResponse: false, + responseBody: responseBuffer, + responseHeaders: proxyResp.headers, + statusCode: proxyResp.statusCode!!, + }; + for (const faultSpec of this.currentFaultSpecs) { + const modResponse = faultSpec.modifyResponse; + if (modResponse) { + modResponse(faultRespContext); + } + } + if (faultRespContext.dropResponse) { + req.destroy(); + return; + } + if (faultRespContext.responseBody) { + // We must accomodate for potentially changed content length + faultRespContext.responseHeaders[ + "content-length" + ] = `${faultRespContext.responseBody.byteLength}`; + } + console.log("writing response head"); + res.writeHead( + faultRespContext.statusCode, + http.STATUS_CODES[faultRespContext.statusCode], + faultRespContext.responseHeaders, + ); + if (faultRespContext.responseBody) { + res.write(faultRespContext.responseBody); + } + res.end(); + }); + }); + }); + }); + + server.listen(this.faultProxyConfig.inboundPort); + this.globalTestState.servers.push(server); + } + + addFault(f: FaultSpec) { + this.currentFaultSpecs.push(f); + } + + clearFault() { + this.currentFaultSpecs = []; + } +} + +export class FaultInjectedExchangeService implements ExchangeServiceInterface { + baseUrl: string; + port: number; + faultProxy: FaultProxy; + + get name(): string { + return this.innerExchange.name; + } + + get masterPub(): string { + return this.innerExchange.masterPub; + } + + private innerExchange: ExchangeService; + + constructor( + t: GlobalTestState, + e: ExchangeService, + proxyInboundPort: number, + ) { + this.innerExchange = e; + this.faultProxy = new FaultProxy(t, { + inboundPort: proxyInboundPort, + targetPort: e.port, + }); + this.faultProxy.start(); + + const exchangeUrl = new URL(e.baseUrl); + exchangeUrl.port = `${proxyInboundPort}`; + this.baseUrl = exchangeUrl.href; + this.port = proxyInboundPort; + } +} diff --git a/packages/taler-integrationtests/src/harness.ts b/packages/taler-integrationtests/src/harness.ts new file mode 100644 index 000000000..14fa2071d --- /dev/null +++ b/packages/taler-integrationtests/src/harness.ts @@ -0,0 +1,907 @@ +/* + 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 + */ + +/** + * Test harness for various GNU Taler components. + * Also provides a fault-injection proxy. + * + * @author Florian Dold + */ + +/** + * Imports + */ +import * as util from "util"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import * as http from "http"; +import { ChildProcess, spawn } from "child_process"; +import { + Configuration, + walletCoreApi, + codec, + AmountJson, + Amounts, +} from "taler-wallet-core"; +import { URL } from "url"; +import axios from "axios"; +import { talerCrypto, time } from "taler-wallet-core"; +import { codecForMerchantOrderPrivateStatusResponse, codecForPostOrderResponse, PostOrderRequest, PostOrderResponse } from "./merchantApiTypes"; + +const exec = util.promisify(require("child_process").exec); + +async function delay(ms: number): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(), ms); + }); +} + +interface WaitResult { + code: number | null; + signal: NodeJS.Signals | null; +} + +/** + * Run a shell command, return stdout. + */ +export async function sh(command: string): Promise { + console.log("runing command"); + console.log(command); + return new Promise((resolve, reject) => { + const stdoutChunks: Buffer[] = []; + const proc = spawn(command, { + stdio: ["inherit", "pipe", "inherit"], + shell: true, + }); + proc.stdout.on("data", (x) => { + console.log("child process got data chunk"); + if (x instanceof Buffer) { + stdoutChunks.push(x); + } else { + throw Error("unexpected data chunk type"); + } + }); + proc.on("exit", (code) => { + console.log("child process exited"); + if (code != 0) { + reject(Error(`Unexpected exit code ${code} for '${command}'`)); + return; + } + const b = Buffer.concat(stdoutChunks).toString("utf-8"); + resolve(b); + }); + proc.on("error", () => { + reject(Error("Child process had error")); + }); + }); +} + +export class ProcessWrapper { + private waitPromise: Promise; + constructor(public proc: ChildProcess) { + this.waitPromise = new Promise((resolve, reject) => { + proc.on("exit", (code, signal) => { + resolve({ code, signal }); + }); + proc.on("error", (err) => { + reject(err); + }); + }); + } + + wait(): Promise { + return this.waitPromise; + } +} + +export function makeTempDir(): Promise { + return new Promise((resolve, reject) => { + fs.mkdtemp( + path.join(os.tmpdir(), "taler-integrationtest-"), + (err, directory) => { + if (err) { + reject(err); + return; + } + resolve(directory); + console.log(directory); + }, + ); + }); +} + +interface CoinConfig { + name: string; + value: string; + durationWithdraw: string; + durationSpend: string; + durationLegal: string; + feeWithdraw: string; + feeDeposit: string; + feeRefresh: string; + feeRefund: string; + rsaKeySize: number; +} + +const coinCommon = { + durationLegal: "3 years", + durationSpend: "2 years", + durationWithdraw: "7 days", + rsaKeySize: 1024, +}; + +const coin_ct1 = (curr: string): CoinConfig => ({ + ...coinCommon, + name: `${curr}_ct1`, + value: `${curr}:0.01`, + feeDeposit: `${curr}:0.00`, + feeRefresh: `${curr}:0.01`, + feeRefund: `${curr}:0.00`, + feeWithdraw: `${curr}:0.01`, +}); + +const coin_ct10 = (curr: string): CoinConfig => ({ + ...coinCommon, + name: `${curr}_ct10`, + value: `${curr}:0.10`, + feeDeposit: `${curr}:0.01`, + feeRefresh: `${curr}:0.01`, + feeRefund: `${curr}:0.00`, + feeWithdraw: `${curr}:0.01`, +}); + +const coin_u1 = (curr: string): CoinConfig => ({ + ...coinCommon, + name: `${curr}_u1`, + value: `${curr}:1`, + feeDeposit: `${curr}:0.02`, + feeRefresh: `${curr}:0.02`, + feeRefund: `${curr}:0.02`, + feeWithdraw: `${curr}:0.02`, +}); + +const coin_u2 = (curr: string): CoinConfig => ({ + ...coinCommon, + name: `${curr}_u2`, + value: `${curr}:2`, + feeDeposit: `${curr}:0.02`, + feeRefresh: `${curr}:0.02`, + feeRefund: `${curr}:0.02`, + feeWithdraw: `${curr}:0.02`, +}); + +const coin_u4 = (curr: string): CoinConfig => ({ + ...coinCommon, + name: `${curr}_u4`, + value: `${curr}:4`, + feeDeposit: `${curr}:0.02`, + feeRefresh: `${curr}:0.02`, + feeRefund: `${curr}:0.02`, + feeWithdraw: `${curr}:0.02`, +}); + +const coin_u8 = (curr: string): CoinConfig => ({ + ...coinCommon, + name: `${curr}_u8`, + value: `${curr}:8`, + feeDeposit: `${curr}:0.16`, + feeRefresh: `${curr}:0.16`, + feeRefund: `${curr}:0.16`, + feeWithdraw: `${curr}:0.16`, +}); + +const coin_u10 = (curr: string): CoinConfig => ({ + ...coinCommon, + name: `${curr}_u10`, + value: `${curr}:10`, + feeDeposit: `${curr}:0.2`, + feeRefresh: `${curr}:0.2`, + feeRefund: `${curr}:0.2`, + feeWithdraw: `${curr}:0.2`, +}); + +export class GlobalTestParams { + testDir: string; +} + +export class GlobalTestState { + testDir: string; + procs: ProcessWrapper[]; + servers: http.Server[]; + constructor(params: GlobalTestParams) { + this.testDir = params.testDir; + this.procs = []; + this.servers = []; + + process.on("SIGINT", () => this.shutdownSync()); + process.on("SIGTERM", () => this.shutdownSync()); + process.on("unhandledRejection", () => this.shutdownSync()); + process.on("uncaughtException", () => this.shutdownSync()); + } + + assertTrue(b: boolean): asserts b { + if (!b) { + throw Error("test assertion failed"); + } + } + + assertAmountEquals( + amtExpected: string | AmountJson, + amtActual: string | AmountJson, + ): void { + let ja1: AmountJson; + let ja2: AmountJson; + if (typeof amtExpected === "string") { + ja1 = Amounts.parseOrThrow(amtExpected); + } else { + ja1 = amtExpected; + } + if (typeof amtActual === "string") { + ja2 = Amounts.parseOrThrow(amtActual); + } else { + ja2 = amtActual; + } + + if (Amounts.cmp(ja1, ja2) != 0) { + throw Error( + `test assertion failed: expected ${Amounts.stringify( + ja1, + )} but got ${Amounts.stringify(ja2)}`, + ); + } + } + + private shutdownSync(): void { + for (const s of this.servers) { + s.close(); + s.removeAllListeners(); + } + for (const p of this.procs) { + if (p.proc.exitCode == null) { + p.proc.kill("SIGTERM"); + } else { + } + } + console.log("*** test harness interrupted"); + console.log("*** test state can be found under", this.testDir); + process.exit(1); + } + + spawnService(command: string, logName: string): ProcessWrapper { + const proc = spawn(command, { + shell: true, + stdio: ["inherit", "pipe", "pipe"], + }); + const stderrLogFileName = this.testDir + `/${logName}-stderr.log`; + const stderrLog = fs.createWriteStream(stderrLogFileName, { + flags: "a", + }); + proc.stderr.pipe(stderrLog); + const stdoutLogFileName = this.testDir + `/${logName}-stdout.log`; + const stdoutLog = fs.createWriteStream(stdoutLogFileName, { + flags: "a", + }); + proc.stdout.pipe(stdoutLog); + const procWrap = new ProcessWrapper(proc); + this.procs.push(procWrap); + return procWrap; + } + + async terminate(): Promise { + console.log("terminating"); + for (const s of this.servers) { + s.close(); + s.removeAllListeners(); + } + for (const p of this.procs) { + if (p.proc.exitCode == null) { + console.log("killing process", p.proc.pid); + p.proc.kill("SIGTERM"); + await p.wait(); + } + } + } +} + +export interface TalerConfigSection { + options: Record; +} + +export interface TalerConfig { + sections: Record; +} + +export interface DbInfo { + connStr: string; + dbname: string; +} + +export async function setupDb(gc: GlobalTestState): Promise { + const dbname = "taler-integrationtest"; + await exec(`dropdb "${dbname}" || true`); + await exec(`createdb "${dbname}"`); + return { + connStr: `postgres:///${dbname}`, + dbname, + }; +} + +export interface BankConfig { + currency: string; + httpPort: number; + database: string; + suggestedExchange: string | undefined; + suggestedExchangePayto: string | undefined; + allowRegistrations: boolean; +} + +function setPaths(config: Configuration, home: string) { + config.setString("paths", "taler_home", home); + config.setString( + "paths", + "taler_data_home", + "$TALER_HOME/.local/share/taler/", + ); + config.setString("paths", "taler_config_home", "$TALER_HOME/.config/taler/"); + config.setString("paths", "taler_cache_home", "$TALER_HOME/.config/taler/"); + config.setString( + "paths", + "taler_runtime_dir", + "${TMPDIR:-${TMP:-/tmp}}/taler-system-runtime/", + ); +} + +function setCoin(config: Configuration, c: CoinConfig) { + const s = `coin_${c.name}`; + config.setString(s, "value", c.value); + config.setString(s, "duration_withdraw", c.durationWithdraw); + config.setString(s, "duration_spend", c.durationSpend); + config.setString(s, "duration_legal", c.durationLegal); + config.setString(s, "fee_deposit", c.feeDeposit); + config.setString(s, "fee_withdraw", c.feeWithdraw); + config.setString(s, "fee_refresh", c.feeRefresh); + config.setString(s, "fee_refund", c.feeRefund); + config.setString(s, "rsa_keysize", `${c.rsaKeySize}`); +} + +export class BankService { + proc: ProcessWrapper | undefined; + static async create( + gc: GlobalTestState, + bc: BankConfig, + ): Promise { + const config = new Configuration(); + setPaths(config, gc.testDir + "/talerhome"); + config.setString("taler", "currency", bc.currency); + config.setString("bank", "database", bc.database); + config.setString("bank", "http_port", `${bc.httpPort}`); + config.setString("bank", "max_debt_bank", `${bc.currency}:999999`); + config.setString( + "bank", + "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); + } + + get port() { + return this.bankConfig.httpPort; + } + + private constructor( + private globalTestState: GlobalTestState, + private bankConfig: BankConfig, + private configFile: string, + ) {} + + async start(): Promise { + this.proc = this.globalTestState.spawnService( + `taler-bank-manage -c "${this.configFile}" serve-http`, + "bank", + ); + } + + 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); + } + } + } + + async createAccount(username: string, password: string): Promise { + const url = `http://localhost:${this.bankConfig.httpPort}/testing/register`; + await axios.post(url, { + username, + password, + }); + } + + async createRandomBankUser(): Promise { + const bankUser: BankUser = { + username: + "user-" + talerCrypto.encodeCrock(talerCrypto.getRandomBytes(10)), + password: "pw-" + talerCrypto.encodeCrock(talerCrypto.getRandomBytes(10)), + }; + await this.createAccount(bankUser.username, bankUser.password); + return bankUser; + } + + async createWithdrawalOperation( + bankUser: BankUser, + amount: string, + ): Promise { + const url = `http://localhost:${this.bankConfig.httpPort}/accounts/${bankUser.username}/withdrawals`; + const resp = await axios.post( + url, + { + amount, + }, + { + auth: bankUser, + }, + ); + return codecForWithdrawalOperationInfo().decode(resp.data); + } + + async confirmWithdrawalOperation( + bankUser: BankUser, + wopi: WithdrawalOperationInfo, + ): Promise { + const url = `http://localhost:${this.bankConfig.httpPort}/accounts/${bankUser.username}/withdrawals/${wopi.withdrawal_id}/confirm`; + await axios.post( + url, + {}, + { + auth: bankUser, + }, + ); + } +} + +export interface BankUser { + username: string; + password: string; +} + +export interface WithdrawalOperationInfo { + withdrawal_id: string; + taler_withdraw_uri: string; +} + +const codecForWithdrawalOperationInfo = (): codec.Codec< + WithdrawalOperationInfo +> => + codec + .makeCodecForObject() + .property("withdrawal_id", codec.codecForString) + .property("taler_withdraw_uri", codec.codecForString) + .build("WithdrawalOperationInfo"); + +export interface ExchangeConfig { + name: string; + currency: string; + roundUnit?: string; + httpPort: number; + database: string; +} + +export interface ExchangeServiceInterface { + readonly baseUrl: string; + readonly port: number; + readonly name: string; + readonly masterPub: string; +} + +export class ExchangeService implements ExchangeServiceInterface { + static create(gc: GlobalTestState, e: ExchangeConfig) { + const config = new Configuration(); + config.setString("taler", "currency", e.currency); + config.setString( + "taler", + "currency_round_unit", + e.roundUnit ?? `${e.currency}:0.01`, + ); + setPaths(config, gc.testDir + "/talerhome"); + + config.setString( + "exchange", + "keydir", + "${TALER_DATA_HOME}/exchange/live-keys/", + ); + config.setString( + "exchage", + "revocation_dir", + "${TALER_DATA_HOME}/exchange/revocations", + ); + config.setString("exchange", "max_keys_caching", "forever"); + config.setString("exchange", "db", "postgres"); + config.setString( + "exchange", + "master_priv_file", + "${TALER_DATA_HOME}/exchange/offline-keys/master.priv", + ); + 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"); + config.setString("exchange", "lookahead_provide", "4 weeks 1 day"); + + for (let i = 2020; i < 2029; i++) { + config.setString( + "fees-x-taler-bank", + `wire-fee-${i}`, + `${e.currency}:0.01`, + ); + config.setString( + "fees-x-taler-bank", + `closing-fee-${i}`, + `${e.currency}:0.01`, + ); + } + + config.setString("exchangedb-postgres", "config", e.database); + + setCoin(config, coin_ct1(e.currency)); + setCoin(config, coin_ct10(e.currency)); + setCoin(config, coin_u1(e.currency)); + setCoin(config, coin_u2(e.currency)); + setCoin(config, coin_u4(e.currency)); + setCoin(config, coin_u8(e.currency)); + setCoin(config, coin_u10(e.currency)); + + const exchangeMasterKey = talerCrypto.createEddsaKeyPair(); + + config.setString( + "exchange", + "master_public_key", + talerCrypto.encodeCrock(exchangeMasterKey.eddsaPub), + ); + + const masterPrivFile = config + .getPath("exchange", "master_priv_file") + .required(); + + fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true }); + + fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv)); + + console.log("writing key to", masterPrivFile); + console.log("pub is", talerCrypto.encodeCrock(exchangeMasterKey.eddsaPub)); + console.log( + "priv is", + talerCrypto.encodeCrock(exchangeMasterKey.eddsaPriv), + ); + + const cfgFilename = gc.testDir + `/exchange-${e.name}.conf`; + config.write(cfgFilename); + return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey); + } + + get masterPub() { + return talerCrypto.encodeCrock(this.keyPair.eddsaPub); + } + + get port() { + return this.exchangeConfig.httpPort; + } + + async setupTestBankAccount( + bc: BankService, + localName: string, + accountName: string, + password: string, + ): Promise { + await bc.createAccount(accountName, password); + const config = Configuration.load(this.configFilename); + config.setString( + `exchange-account-${localName}`, + "wire_response", + `\${TALER_DATA_HOME}/exchange/account-${localName}.json`, + ); + config.setString( + `exchange-account-${localName}`, + "payto_uri", + `payto://x-taler-bank/localhost/${accountName}`, + ); + config.setString(`exchange-account-${localName}`, "enable_credit", "yes"); + config.setString(`exchange-account-${localName}`, "enable_debit", "yes"); + config.setString( + `exchange-account-${localName}`, + "wire_gateway_url", + `http://localhost:${bc.port}/taler-wire-gateway/${accountName}/`, + ); + config.setString( + `exchange-account-${localName}`, + "wire_gateway_auth_method", + "basic", + ); + config.setString(`exchange-account-${localName}`, "username", accountName); + config.setString(`exchange-account-${localName}`, "password", password); + config.write(this.configFilename); + } + + exchangeHttpProc: ProcessWrapper | undefined; + exchangeWirewatchProc: ProcessWrapper | undefined; + + constructor( + private globalState: GlobalTestState, + private exchangeConfig: ExchangeConfig, + private configFilename: string, + private keyPair: talerCrypto.EddsaKeyPair, + ) {} + + get name() { + return this.exchangeConfig.name; + } + + get baseUrl() { + return `http://localhost:${this.exchangeConfig.httpPort}/`; + } + + async start(): Promise { + await exec(`taler-exchange-dbinit -c "${this.configFilename}"`); + await exec(`taler-exchange-wire -c "${this.configFilename}"`); + await exec(`taler-exchange-keyup -c "${this.configFilename}"`); + + this.exchangeWirewatchProc = this.globalState.spawnService( + `taler-exchange-wirewatch -c "${this.configFilename}"`, + `exchange-wirewatch-${this.name}`, + ); + + this.exchangeHttpProc = this.globalState.spawnService( + `taler-exchange-httpd -c "${this.configFilename}"`, + `exchange-httpd-${this.name}`, + ); + } + + async pingUntilAvailable(): Promise { + const url = `http://localhost:${this.exchangeConfig.httpPort}/keys`; + while (true) { + try { + console.log("pinging exchange"); + const resp = await axios.get(url); + console.log(resp.data); + return; + } catch (e) { + console.log("exchange not ready:", e.toString()); + await delay(1000); + } + } + } +} + +export interface MerchantConfig { + name: string; + currency: string; + httpPort: number; + database: string; +} + +export class MerchantService { + proc: ProcessWrapper | undefined; + + constructor( + private globalState: GlobalTestState, + private merchantConfig: MerchantConfig, + private configFilename: string, + ) {} + + async start(): Promise { + await exec(`taler-merchant-dbinit -c "${this.configFilename}"`); + + this.proc = this.globalState.spawnService( + `taler-merchant-httpd -c "${this.configFilename}"`, + `merchant-${this.merchantConfig.name}`, + ); + } + + static async create( + gc: GlobalTestState, + mc: MerchantConfig, + ): Promise { + const config = new Configuration(); + config.setString("taler", "currency", mc.currency); + + config.setString("merchant", "serve", "tcp"); + config.setString("merchant", "port", `${mc.httpPort}`); + config.setString("merchant", "db", "postgres"); + config.setString("exchangedb-postgres", "config", mc.database); + + const cfgFilename = gc.testDir + `/merchant-${mc.name}.conf`; + config.write(cfgFilename); + + return new MerchantService(gc, mc, cfgFilename); + } + + addExchange(e: ExchangeServiceInterface): void { + const config = Configuration.load(this.configFilename); + config.setString( + `merchant-exchange-${e.name}`, + "exchange_base_url", + e.baseUrl, + ); + config.setString( + `merchant-exchange-${e.name}`, + "currency", + this.merchantConfig.currency, + ); + config.setString(`merchant-exchange-${e.name}`, "master_key", e.masterPub); + config.write(this.configFilename); + } + + async addInstance(instanceConfig: MerchantInstanceConfig): Promise { + if (!this.proc) { + throw Error("merchant must be running to add instance"); + } + console.log("adding instance"); + const url = `http://localhost:${this.merchantConfig.httpPort}/private/instances`; + await axios.post(url, { + payto_uris: instanceConfig.paytoUris, + id: instanceConfig.id, + name: instanceConfig.name, + address: instanceConfig.address ?? {}, + jurisdiction: instanceConfig.jurisdiction ?? {}, + default_max_wire_fee: + instanceConfig.defaultMaxWireFee ?? + `${this.merchantConfig.currency}:1.0`, + default_wire_fee_amortization: + instanceConfig.defaultWireFeeAmortization ?? 3, + default_max_deposit_fee: + instanceConfig.defaultMaxDepositFee ?? + `${this.merchantConfig.currency}:1.0`, + default_wire_transfer_delay: instanceConfig.defaultWireTransferDelay ?? { + d_ms: "forever", + }, + default_pay_delay: instanceConfig.defaultPayDelay ?? { d_ms: "forever" }, + }); + } + + async queryPrivateOrderStatus(instanceName: string, orderId: string) { + let url; + if (instanceName === "default") { + url = `http://localhost:${this.merchantConfig.httpPort}/private/orders/${orderId}` + } else { + url = `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/private/orders/${orderId}`; + } + const resp = await axios.get(url); + return codecForMerchantOrderPrivateStatusResponse().decode(resp.data); + } + + async createOrder( + instanceName: string, + req: PostOrderRequest, + ): Promise { + let url; + if (instanceName === "default") { + url = `http://localhost:${this.merchantConfig.httpPort}/private/orders`; + } else { + url = `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/private/orders`; + } + const resp = await axios.post(url, req); + return codecForPostOrderResponse().decode(resp.data); + } + + async pingUntilAvailable(): Promise { + const url = `http://localhost:${this.merchantConfig.httpPort}/config`; + while (true) { + try { + console.log("pinging merchant"); + const resp = await axios.get(url); + console.log(resp.data); + return; + } catch (e) { + console.log("merchant not ready", e.toString()); + await delay(1000); + } + } + } +} + +export interface MerchantInstanceConfig { + id: string; + name: string; + paytoUris: string[]; + address?: unknown; + jurisdiction?: unknown; + defaultMaxWireFee?: string; + defaultMaxDepositFee?: string; + defaultWireFeeAmortization?: number; + defaultWireTransferDelay?: time.Duration; + defaultPayDelay?: time.Duration; +} + +export function runTest(testMain: (gc: GlobalTestState) => Promise) { + const main = async () => { + const gc = new GlobalTestState({ + testDir: await makeTempDir(), + }); + try { + await testMain(gc); + } finally { + if (process.env["TALER_TEST_KEEP"] !== "1") { + await gc.terminate(); + console.log("test logs and config can be found under", gc.testDir); + } + } + }; + + main().catch((e) => { + console.error("FATAL: test failed with exception"); + if (e instanceof Error) { + console.error(e); + } else { + console.error(e); + } + + if (process.env["TALER_TEST_KEEP"] !== "1") { + process.exit(1); + } + }); +} + +function shellWrap(s: string) { + return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"; +} + +export class WalletCli { + constructor(private globalTestState: GlobalTestState) {} + + async apiRequest( + request: string, + payload: Record, + ): Promise { + const wdb = this.globalTestState.testDir + "/walletdb.json"; + const resp = await sh( + `taler-wallet-cli --no-throttle --wallet-db '${wdb}' api '${request}' ${shellWrap( + JSON.stringify(payload), + )}`, + ); + console.log(resp); + return JSON.parse(resp) as walletCoreApi.CoreApiResponse; + } + + async runUntilDone(): Promise { + const wdb = this.globalTestState.testDir + "/walletdb.json"; + await sh(`taler-wallet-cli --no-throttle --wallet-db ${wdb} run-until-done`); + } + + async runPending(): Promise { + const wdb = this.globalTestState.testDir + "/walletdb.json"; + await sh(`taler-wallet-cli --no-throttle --wallet-db ${wdb} run-pending`); + } +} diff --git a/packages/taler-integrationtests/src/helpers.ts b/packages/taler-integrationtests/src/helpers.ts new file mode 100644 index 000000000..01362370c --- /dev/null +++ b/packages/taler-integrationtests/src/helpers.ts @@ -0,0 +1,157 @@ +/* + 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 + */ + +/** + * Helpers to create typical test environments. + * + * @author Florian Dold + */ + +/** + * Imports + */ +import { + GlobalTestState, + DbInfo, + ExchangeService, + WalletCli, + MerchantService, + setupDb, + BankService, +} from "./harness"; +import { AmountString } from "taler-wallet-core/lib/types/talerTypes"; + +export interface SimpleTestEnvironment { + commonDb: DbInfo; + bank: BankService; + exchange: ExchangeService; + merchant: MerchantService; + wallet: WalletCli; +} + +/** + * Run a test case with a simple TESTKUDOS Taler environment, consisting + * of one exchange, one bank and one merchant. + */ +export async function createSimpleTestkudosEnvironment( + t: GlobalTestState, +): Promise { + const db = await setupDb(t); + + const bank = await BankService.create(t, { + allowRegistrations: true, + 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, + }); + + await exchange.setupTestBankAccount(bank, "1", "MyExchange", "x"); + + await exchange.start(); + await exchange.pingUntilAvailable(); + + const merchant = await MerchantService.create(t, { + name: "testmerchant-1", + currency: "TESTKUDOS", + httpPort: 8083, + database: db.connStr, + }); + + 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!"); + + const wallet = new WalletCli(t); + + return { + commonDb: db, + exchange, + merchant, + wallet, + bank, + }; +} + +/** + * Withdraw balance. + */ +export async function withdrawViaBank(t: GlobalTestState, p: { + wallet: WalletCli; + bank: BankService; + exchange: ExchangeService; + amount: AmountString; +}): Promise { + + const { wallet, bank, exchange, amount } = p; + + const user = await bank.createRandomBankUser(); + const wop = await bank.createWithdrawalOperation(user, amount); + + // Hand it to the wallet + + const r1 = await wallet.apiRequest("getWithdrawalDetailsForUri", { + talerWithdrawUri: wop.taler_withdraw_uri, + }); + t.assertTrue(r1.type === "response"); + + await wallet.runPending(); + + // Confirm it + + await bank.confirmWithdrawalOperation(user, wop); + + // Withdraw + + const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", { + exchangeBaseUrl: exchange.baseUrl, + talerWithdrawUri: wop.taler_withdraw_uri, + }); + t.assertTrue(r2.type === "response"); + await wallet.runUntilDone(); + + // Check balance + + const balApiResp = await wallet.apiRequest("getBalances", {}); + t.assertTrue(balApiResp.type === "response"); +} diff --git a/packages/taler-integrationtests/src/merchantApiTypes.ts b/packages/taler-integrationtests/src/merchantApiTypes.ts new file mode 100644 index 000000000..412b9bb8b --- /dev/null +++ b/packages/taler-integrationtests/src/merchantApiTypes.ts @@ -0,0 +1,217 @@ +/* + 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 + */ + +/** + * Test harness for various GNU Taler components. + * Also provides a fault-injection proxy. + * + * @author Florian Dold + */ + +/** + * Imports + */ +import { + codec, + talerTypes, + time, +} from "taler-wallet-core"; + + +export interface PostOrderRequest { + // The order must at least contain the minimal + // order detail, but can override all + order: Partial; + + // if set, the backend will then set the refund deadline to the current + // time plus the specified delay. + refund_delay?: time.Duration; + + // specifies the payment target preferred by the client. Can be used + // to select among the various (active) wire methods supported by the instance. + payment_target?: string; + + // FIXME: some fields are missing + + // Should a token for claiming the order be generated? + // False can make sense if the ORDER_ID is sufficiently + // high entropy to prevent adversarial claims (like it is + // if the backend auto-generates one). Default is 'true'. + create_token?: boolean; +} + +export type ClaimToken = string; + +export interface PostOrderResponse { + order_id: string; + token?: ClaimToken; +} + +export const codecForPostOrderResponse = (): codec.Codec => + codec + .makeCodecForObject() + .property("order_id", codec.codecForString) + .property("token", codec.makeCodecOptional(codec.codecForString)) + .build("PostOrderResponse"); + +export const codecForCheckPaymentPaidResponse = (): codec.Codec< + CheckPaymentPaidResponse +> => + codec + .makeCodecForObject() + .property("order_status", codec.makeCodecForConstString("paid")) + .property("refunded", codec.codecForBoolean) + .property("wired", codec.codecForBoolean) + .property("deposit_total", codec.codecForString) + .property("exchange_ec", codec.codecForNumber) + .property("exchange_hc", codec.codecForNumber) + .property("refund_amount", codec.codecForString) + .property("contract_terms", talerTypes.codecForContractTerms()) + // FIXME: specify + .property("wire_details", codec.codecForAny) + .property("wire_reports", codec.codecForAny) + .property("refund_details", codec.codecForAny) + .build("CheckPaymentPaidResponse"); + +export const codecForCheckPaymentUnpaidResponse = (): codec.Codec< + CheckPaymentUnpaidResponse +> => + codec + .makeCodecForObject() + .property("order_status", codec.makeCodecForConstString("unpaid")) + .property("taler_pay_uri", codec.codecForString) + .property( + "already_paid_order_id", + codec.makeCodecOptional(codec.codecForString), + ) + .build("CheckPaymentPaidResponse"); + +export const codecForMerchantOrderPrivateStatusResponse = (): codec.Codec< + MerchantOrderPrivateStatusResponse +> => + codec + .makeCodecForUnion() + .discriminateOn("order_status") + .alternative("paid", codecForCheckPaymentPaidResponse()) + .alternative("unpaid", codecForCheckPaymentUnpaidResponse()) + .build("MerchantOrderPrivateStatusResponse"); + +export type MerchantOrderPrivateStatusResponse = + | CheckPaymentPaidResponse + | CheckPaymentUnpaidResponse; + +export interface CheckPaymentPaidResponse { + // did the customer pay for this contract + order_status: "paid"; + + // Was the payment refunded (even partially) + refunded: boolean; + + // Did the exchange wire us the funds + wired: boolean; + + // Total amount the exchange deposited into our bank account + // for this contract, excluding fees. + deposit_total: talerTypes.AmountString; + + // Numeric error code indicating errors the exchange + // encountered tracking the wire transfer for this purchase (before + // we even got to specific coin issues). + // 0 if there were no issues. + exchange_ec: number; + + // HTTP status code returned by the exchange when we asked for + // information to track the wire transfer for this purchase. + // 0 if there were no issues. + exchange_hc: number; + + // Total amount that was refunded, 0 if refunded is false. + refund_amount: talerTypes.AmountString; + + // Contract terms + contract_terms: talerTypes.ContractTerms; + + // Ihe wire transfer status from the exchange for this order if available, otherwise empty array + wire_details: TransactionWireTransfer[]; + + // Reports about trouble obtaining wire transfer details, empty array if no trouble were encountered. + wire_reports: TransactionWireReport[]; + + // The refund details for this order. One entry per + // refunded coin; empty array if there are no refunds. + refund_details: RefundDetails[]; +} + +export interface CheckPaymentUnpaidResponse { + order_status: "unpaid"; + + // URI that the wallet must process to complete the payment. + taler_pay_uri: string; + + // Alternative order ID which was paid for already in the same session. + // Only given if the same product was purchased before in the same session. + already_paid_order_id?: string; + + // We do we NOT return the contract terms here because they may not + // exist in case the wallet did not yet claim them. +} + +export interface RefundDetails { + // Reason given for the refund + reason: string; + + // when was the refund approved + timestamp: time.Timestamp; + + // Total amount that was refunded (minus a refund fee). + amount: talerTypes.AmountString; +} + +export interface TransactionWireTransfer { + // Responsible exchange + exchange_url: string; + + // 32-byte wire transfer identifier + wtid: string; + + // execution time of the wire transfer + execution_time: time.Timestamp; + + // Total amount that has been wire transfered + // to the merchant + amount: talerTypes.AmountString; + + // Was this transfer confirmed by the merchant via the + // POST /transfers API, or is it merely claimed by the exchange? + confirmed: boolean; +} + +export interface TransactionWireReport { + // Numerical error code + code: number; + + // Human-readable error description + hint: string; + + // Numerical error code from the exchange. + exchange_ec: number; + + // HTTP status code received from the exchange. + exchange_hc: number; + + // Public key of the coin for which we got the exchange error. + coin_pub: talerTypes.CoinPublicKeyString; +} diff --git a/packages/taler-integrationtests/src/test-payment-fault.ts b/packages/taler-integrationtests/src/test-payment-fault.ts new file mode 100644 index 000000000..2e0448880 --- /dev/null +++ b/packages/taler-integrationtests/src/test-payment-fault.ts @@ -0,0 +1,194 @@ +/* + 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 + */ + +/** + * Sample fault injection test. + */ + +/** + * Imports. + */ +import { + runTest, + GlobalTestState, + MerchantService, + ExchangeService, + setupDb, + BankService, + WalletCli, +} from "./harness"; +import { FaultInjectedExchangeService, FaultInjectionRequestContext, FaultInjectionResponseContext } from "./faultInjection"; +import { CoreApiResponse } from "taler-wallet-core/lib/walletCoreApiHandler"; + +/** + * Run test for basic, bank-integrated withdrawal. + */ +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, + 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", + httpPort: 8081, + database: db.connStr, + }); + + await exchange.setupTestBankAccount(bank, "1", "MyExchange", "x"); + + await exchange.start(); + await exchange.pingUntilAvailable(); + + const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091); + + // Print all requests to the exchange + faultyExchange.faultProxy.addFault({ + modifyRequest(ctx: FaultInjectionRequestContext) { + console.log("got request", ctx); + }, + modifyResponse(ctx: FaultInjectionResponseContext) { + console.log("got response", ctx); + } + }); + + const merchant = await MerchantService.create(t, { + name: "testmerchant-1", + currency: "TESTKUDOS", + httpPort: 8083, + database: db.connStr, + }); + + merchant.addExchange(faultyExchange); + + await merchant.start(); + await merchant.pingUntilAvailable(); + + await merchant.addInstance({ + id: "default", + name: "Default Instance", + paytoUris: [`payto://x-taler-bank/merchant-default`], + }); + + console.log("setup done!"); + + const wallet = new WalletCli(t); + + // Create withdrawal operation + + const user = await bank.createRandomBankUser(); + const wop = await bank.createWithdrawalOperation(user, "TESTKUDOS:20"); + + // Hand it to the wallet + + const r1 = await wallet.apiRequest("getWithdrawalDetailsForUri", { + talerWithdrawUri: wop.taler_withdraw_uri, + }); + t.assertTrue(r1.type === "response"); + + await wallet.runPending(); + + // Confirm it + + await bank.confirmWithdrawalOperation(user, wop); + + // Withdraw + + const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", { + exchangeBaseUrl: faultyExchange.baseUrl, + talerWithdrawUri: wop.taler_withdraw_uri, + }); + t.assertTrue(r2.type === "response"); + await wallet.runUntilDone(); + + // Check balance + + const balApiResp = await wallet.apiRequest("getBalances", {}); + t.assertTrue(balApiResp.type === "response"); + + // Set up order. + + const orderResp = await merchant.createOrder("default", { + order: { + summary: "Buy me!", + amount: "TESTKUDOS:5", + 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 + + let apiResp: CoreApiResponse; + + apiResp = await wallet.apiRequest("preparePay", { + talerPayUri: orderStatus.taler_pay_uri, + }); + t.assertTrue(apiResp.type === "response"); + + const proposalId = (apiResp.result as any).proposalId; + + await wallet.runPending(); + + // Drop 10 responses from the exchange. + let faultCount = 0; + faultyExchange.faultProxy.addFault({ + modifyResponse(ctx: FaultInjectionResponseContext) { + if (faultCount < 10) { + faultCount++; + ctx.dropResponse = true; + } + } + }); + + // confirmPay won't work, as the exchange is unreachable + + apiResp = await wallet.apiRequest("confirmPay", { + // FIXME: should be validated, don't cast! + proposalId: proposalId, + }); + t.assertTrue(apiResp.type === "error"); + + await wallet.runUntilDone(); + + // Check if payment was successful. + + orderStatus = await merchant.queryPrivateOrderStatus( + "default", + orderResp.order_id, + ); + + t.assertTrue(orderStatus.order_status === "paid"); +}); diff --git a/packages/taler-integrationtests/src/test-payment.ts b/packages/taler-integrationtests/src/test-payment.ts new file mode 100644 index 000000000..fe44c183f --- /dev/null +++ b/packages/taler-integrationtests/src/test-payment.ts @@ -0,0 +1,80 @@ +/* + 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 } from "./harness"; +import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers"; + +/** + * Run test for basic, bank-integrated withdrawal. + */ +runTest(async (t: GlobalTestState) => { + // Set up test environment + + const { + wallet, + bank, + exchange, + merchant, + } = await createSimpleTestkudosEnvironment(t); + + // Withdraw digital cash into the wallet. + + await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" }); + + // Set up order. + + const orderResp = await merchant.createOrder("default", { + order: { + summary: "Buy me!", + amount: "TESTKUDOS:5", + 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.terminate(); +}); diff --git a/packages/taler-integrationtests/src/test-withdrawal.ts b/packages/taler-integrationtests/src/test-withdrawal.ts new file mode 100644 index 000000000..67720a8a2 --- /dev/null +++ b/packages/taler-integrationtests/src/test-withdrawal.ts @@ -0,0 +1,68 @@ +/* + 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 } from "./harness"; +import { createSimpleTestkudosEnvironment } from "./helpers"; +import { walletTypes } from "taler-wallet-core"; + +/** + * Run test for basic, bank-integrated withdrawal. + */ +runTest(async (t: GlobalTestState) => { + + // Set up test environment + + const { wallet, bank, exchange } = await createSimpleTestkudosEnvironment(t); + + // Create a withdrawal operation + + const user = await bank.createRandomBankUser(); + const wop = await bank.createWithdrawalOperation(user, "TESTKUDOS:10"); + + // Hand it to the wallet + + const r1 = await wallet.apiRequest("getWithdrawalDetailsForUri", { + talerWithdrawUri: wop.taler_withdraw_uri, + }); + t.assertTrue(r1.type === "response"); + + await wallet.runPending(); + + // Confirm it + + await bank.confirmWithdrawalOperation(user, wop); + + // Withdraw + + const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", { + exchangeBaseUrl: exchange.baseUrl, + talerWithdrawUri: wop.taler_withdraw_uri, + }); + t.assertTrue(r2.type === "response"); + await wallet.runUntilDone(); + + // Check balance + + const balApiResp = await wallet.apiRequest("getBalances", {}); + t.assertTrue(balApiResp.type === "response"); + const balResp = walletTypes.codecForBalancesResponse().decode(balApiResp.result); + t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available) + + await t.terminate(); +}); diff --git a/packages/taler-integrationtests/testrunner b/packages/taler-integrationtests/testrunner new file mode 100755 index 000000000..282624500 --- /dev/null +++ b/packages/taler-integrationtests/testrunner @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +# Simple test runner for the wallet integration tests. +# +# Usage: $0 TESTGLOB +# +# The TESTGLOB can be used to select which test cases to execute + +set -eu + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 TESTGLOB" + exit 1 +fi + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +cd $DIR + +./node_modules/.bin/tsc + +export ESM_OPTIONS='{"sourceMap": true}' + +shopt -s extglob + +num_exec=0 +num_fail=0 +num_succ=0 + +# Glob tests +for file in lib/$1?(.js); do + case "$file" in + *.js) + echo "executing test $file" + ret=0 + node -r source-map-support/register -r esm $file || ret=$? + num_exec=$((num_exec+1)) + case $ret in + 0) + num_succ=$((num_succ+1)) + ;; + *) + num_fail=$((num_fail+1)) + ;; + esac + ;; + *) + continue + ;; + esac +done + +echo "-----------------------------------" +echo "Tests finished" +echo "$num_succ/$num_exec tests succeeded" +echo "-----------------------------------" + +if [[ $num_fail = 0 ]]; then + exit 0 +else + exit 1 +fi + diff --git a/packages/taler-integrationtests/tsconfig.json b/packages/taler-integrationtests/tsconfig.json new file mode 100644 index 000000000..07e8ab0bf --- /dev/null +++ b/packages/taler-integrationtests/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": false, + "target": "ES6", + "module": "ESNext", + "moduleResolution": "node", + "sourceMap": true, + "lib": ["es6"], + "types": ["node"], + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "strict": true, + "strictPropertyInitialization": false, + "outDir": "lib", + "noImplicitAny": true, + "noImplicitThis": true, + "incremental": true, + "esModuleInterop": true, + "importHelpers": true, + "rootDir": "./src", + "typeRoots": ["./node_modules/@types"] + }, + "references": [ + { + "path": "../idb-bridge/" + } + ], + "include": ["src/**/*"] +} diff --git a/packages/taler-wallet-android/src/index.ts b/packages/taler-wallet-android/src/index.ts index d0001e991..c949a4773 100644 --- a/packages/taler-wallet-android/src/index.ts +++ b/packages/taler-wallet-android/src/index.ts @@ -113,6 +113,7 @@ export class AndroidHttpLib implements httpLib.HttpRequestLibrary { requestUrl: "", headers, status: msg.status, + requestMethod: "FIXME", json: async () => JSON.parse(msg.responseText), text: async () => msg.responseText, }; diff --git a/packages/taler-wallet-cli/bin/taler-wallet-cli b/packages/taler-wallet-cli/bin/taler-wallet-cli index 871514024..756de2027 100755 --- a/packages/taler-wallet-cli/bin/taler-wallet-cli +++ b/packages/taler-wallet-cli/bin/taler-wallet-cli @@ -4,4 +4,4 @@ try { } catch (e) { // Do nothing. } -require('../dist/taler-wallet-cli.js') +require('../dist/taler-wallet-cli.js').walletCli.run(); diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index c8e517e53..ae5371ecc 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -34,6 +34,12 @@ import { NodeHttpLib, } from "taler-wallet-core"; import * as clk from "./clk"; +import { NodeThreadCryptoWorkerFactory } from "taler-wallet-core/lib/crypto/workers/nodeThreadWorker"; +import { CryptoApi } from "taler-wallet-core/lib/crypto/workers/cryptoApi"; + +// This module also serves as the entry point for the crypto +// thread worker, and thus must expose these two handlers. +export { handleWorkerError, handleWorkerMessage } from "taler-wallet-core"; const logger = new Logger("taler-wallet-cli.ts"); @@ -109,7 +115,7 @@ function printVersion(): void { process.exit(0); } -const walletCli = clk +export const walletCli = clk .program("wallet", { help: "Command line interface for the GNU Taler wallet.", }) @@ -637,4 +643,9 @@ testCli.subcommand("vectors", "vectors").action(async (args) => { testvectors.printTestVectors(); }); -walletCli.run(); +testCli.subcommand("cryptoworker", "cryptoworker").action(async (args) => { + const workerFactory = new NodeThreadCryptoWorkerFactory(); + const cryptoApi = new CryptoApi(workerFactory); + const res = await cryptoApi.hashString("foo"); + console.log(res); +}); diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index 20240bab4..68bf45d0a 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -34,25 +34,27 @@ "@typescript-eslint/eslint-plugin": "^3.6.1", "@typescript-eslint/parser": "^3.6.1", "ava": "^3.10.1", + "dts-bundle-generator": "^5.3.0", "eslint": "^7.4.0", "eslint-config-airbnb-typescript": "^8.0.2", "eslint-plugin-import": "^2.22.0", "eslint-plugin-jsx-a11y": "^6.3.1", "eslint-plugin-react": "^7.20.3", "eslint-plugin-react-hooks": "^4.0.8", + "esm": "^3.2.25", "jed": "^1.1.1", "moment": "^2.27.0", "nyc": "^15.1.0", "po2json": "^0.4.5", "pogen": "workspace:*", "prettier": "^2.0.5", + "rimraf": "^3.0.2", + "rollup": "^2.23.0", + "rollup-plugin-sourcemaps": "^0.6.2", "source-map-resolve": "^0.6.0", "structured-clone": "^0.2.2", "typedoc": "^0.17.8", - "typescript": "^3.9.7", - "rollup": "^2.23.0", - "esm": "^3.2.25", - "rimraf": "^3.0.2" + "typescript": "^3.9.7" }, "dependencies": { "@types/node": "^14.0.27", @@ -63,7 +65,9 @@ "tslib": "^2.0.0" }, "ava": { - "require": ["esm"], + "require": [ + "esm" + ], "files": [ "src/**/*-test.*" ], diff --git a/packages/taler-wallet-core/rollup.config.js b/packages/taler-wallet-core/rollup.config.js index 2f0a86b2a..bcc8e5b26 100644 --- a/packages/taler-wallet-core/rollup.config.js +++ b/packages/taler-wallet-core/rollup.config.js @@ -4,13 +4,14 @@ import nodeResolve from "@rollup/plugin-node-resolve"; import json from "@rollup/plugin-json"; import builtins from "builtin-modules"; import pkg from "./package.json"; +import sourcemaps from 'rollup-plugin-sourcemaps'; export default { input: "lib/index.js", output: { file: pkg.main, format: "cjs", - sourcemap: false, + sourcemap: true, }, external: builtins, plugins: [ @@ -18,11 +19,13 @@ export default { preferBuiltins: true, }), + sourcemaps(), + commonjs({ include: [/node_modules/, /dist/], extensions: [".js"], ignoreGlobal: false, - sourceMap: false, + sourceMap: true, }), json(), diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts index a272d5724..20d13a3f2 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts @@ -1,17 +1,17 @@ /* - This file is part of TALER + This file is part of GNU Taler (C) 2016 GNUnet e.V. - TALER is free software; you can redistribute it and/or modify it under the + 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. - TALER is distributed in the hope that it will be useful, but WITHOUT ANY + 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 - TALER; see the file COPYING. If not, see + GNU Taler; see the file COPYING. If not, see */ /** @@ -46,6 +46,7 @@ import { import * as timer from "../../util/timer"; import { Logger } from "../../util/logging"; +import { walletCoreApi } from "../.."; const logger = new Logger("cryptoApi.ts"); @@ -182,7 +183,7 @@ export class CryptoApi { }; this.resetWorkerTimeout(ws); work.startTime = timer.performanceNow(); - setTimeout(() => worker.postMessage(msg), 0); + timer.after(0, () => worker.postMessage(msg)); } resetWorkerTimeout(ws: WorkerState): void { @@ -198,6 +199,7 @@ export class CryptoApi { } }; ws.terminationTimerHandle = timer.after(15 * 1000, destroy); + //ws.terminationTimerHandle.unref(); } handleWorkerError(ws: WorkerState, e: any): void { diff --git a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts index 6c9dfc569..d4d858330 100644 --- a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts +++ b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts @@ -21,6 +21,9 @@ import { CryptoWorkerFactory } from "./cryptoApi"; import { CryptoWorker } from "./cryptoWorker"; import os from "os"; import { CryptoImplementation } from "./cryptoImplementation"; +import { Logger } from "../../util/logging"; + +const logger = new Logger("nodeThreadWorker.ts"); const f = __filename; @@ -37,16 +40,22 @@ const workerCode = ` try { tw = require("${f}"); } catch (e) { - console.log("could not load from ${f}"); + console.warn("could not load from ${f}"); } if (!tw) { try { tw = require("taler-wallet-android"); } catch (e) { - console.log("could not load taler-wallet-android either"); + console.warn("could not load taler-wallet-android either"); throw e; } } + if (typeof tw.handleWorkerMessage !== "function") { + throw Error("module loaded for crypto worker lacks handleWorkerMessage"); + } + if (typeof tw.handleWorkerError !== "function") { + throw Error("module loaded for crypto worker lacks handleWorkerError"); + } parentPort.on("message", tw.handleWorkerMessage); parentPort.on("error", tw.handleWorkerError); `; @@ -138,6 +147,9 @@ class NodeThreadCryptoWorker implements CryptoWorker { constructor() { // eslint-disable-next-line @typescript-eslint/no-var-requires const worker_threads = require("worker_threads"); + + logger.trace("starting node crypto worker"); + this.nodeWorker = new worker_threads.Worker(workerCode, { eval: true }); this.nodeWorker.on("error", (err: Error) => { console.error("error in node worker:", err); @@ -145,6 +157,9 @@ class NodeThreadCryptoWorker implements CryptoWorker { this.onerror(err); } }); + this.nodeWorker.on("exit", (err) => { + logger.trace(`worker exited with code ${err}`); + }); this.nodeWorker.on("message", (v: any) => { if (this.onmessage) { this.onmessage(v); diff --git a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts b/packages/taler-wallet-core/src/headless/NodeHttpLib.ts index d109c3b7c..59730ab30 100644 --- a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts +++ b/packages/taler-wallet-core/src/headless/NodeHttpLib.ts @@ -45,7 +45,7 @@ export class NodeHttpLib implements HttpRequestLibrary { } private async req( - method: "post" | "get", + method: "POST" | "GET", url: string, body: any, opt?: HttpRequestOptions, @@ -72,6 +72,7 @@ export class NodeHttpLib implements HttpRequestLibrary { { httpStatusCode: resp.status, requestUrl: url, + requestMethod: method, }, ), ); @@ -88,6 +89,7 @@ export class NodeHttpLib implements HttpRequestLibrary { { httpStatusCode: resp.status, requestUrl: url, + requestMethod: method, }, ), ); @@ -100,6 +102,7 @@ export class NodeHttpLib implements HttpRequestLibrary { { httpStatusCode: resp.status, requestUrl: url, + requestMethod: method, }, ), ); @@ -112,6 +115,7 @@ export class NodeHttpLib implements HttpRequestLibrary { } return { requestUrl: url, + requestMethod: method, headers, status: resp.status, text: async () => resp.data, @@ -120,7 +124,7 @@ export class NodeHttpLib implements HttpRequestLibrary { } async get(url: string, opt?: HttpRequestOptions): Promise { - return this.req("get", url, undefined, opt); + return this.req("GET", url, undefined, opt); } async postJson( @@ -128,6 +132,6 @@ export class NodeHttpLib implements HttpRequestLibrary { body: any, opt?: HttpRequestOptions, ): Promise { - return this.req("post", url, body, opt); + return this.req("POST", url, body, opt); } } diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts index e70fc44f6..5c4961bd7 100644 --- a/packages/taler-wallet-core/src/index.ts +++ b/packages/taler-wallet-core/src/index.ts @@ -73,3 +73,10 @@ export * as i18n from "./i18n"; export * as nodeThreadWorker from "./crypto/workers/nodeThreadWorker"; export * as walletNotifications from "./types/notifications"; + +export { Configuration } from "./util/talerconfig"; + +export { + handleWorkerMessage, + handleWorkerError, +} from "./crypto/workers/nodeThreadWorker"; diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index ee49fddb5..8967173ca 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -112,6 +112,8 @@ async function updateExchangeWithKeys( return; } + logger.info("updating exchange /keys info"); + const keysUrl = new URL("keys", baseUrl); keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); @@ -121,6 +123,8 @@ async function updateExchangeWithKeys( codecForExchangeKeysJson(), ); + logger.info("received /keys response"); + if (exchangeKeysJson.denoms.length === 0) { const opErr = makeErrorDetails( TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT, @@ -152,12 +156,16 @@ async function updateExchangeWithKeys( const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) .currency; + logger.trace("processing denominations"); + const newDenominations = await Promise.all( exchangeKeysJson.denoms.map((d) => denominationRecordFromKeys(ws, baseUrl, d), ), ); + logger.trace("done with processing denominations"); + const lastUpdateTimestamp = getTimestampNow(); const recoupGroupId: string | undefined = undefined; @@ -241,6 +249,8 @@ async function updateExchangeWithKeys( console.log("error while recouping coins:", e); }); } + + logger.trace("done updating exchange /keys"); } async function updateExchangeFinalize( diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index f23e326f8..0fa9e0a61 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -781,7 +781,7 @@ export async function submitPay( } const sessionId = purchase.lastSessionId; - console.log("paying with session ID", sessionId); + logger.trace("paying with session ID", sessionId); const payUrl = new URL( `orders/${purchase.contractData.orderId}/pay`, diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index 3b0aa0095..9719772a7 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -712,7 +712,9 @@ export async function getWithdrawalDetailsForUri( ws: InternalWalletState, talerWithdrawUri: string, ): Promise { + logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); const info = await getBankWithdrawalInfo(ws, talerWithdrawUri); + logger.trace(`got bank info`); if (info.suggestedExchange) { // FIXME: right now the exchange gets permanently added, // we might want to only temporarily add it. diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts b/packages/taler-wallet-core/src/types/walletTypes.ts index 04f50f29a..83275a0cc 100644 --- a/packages/taler-wallet-core/src/types/walletTypes.ts +++ b/packages/taler-wallet-core/src/types/walletTypes.ts @@ -40,8 +40,11 @@ import { codecForString, makeCodecOptional, Codec, + makeCodecForList, + codecForBoolean, } from "../util/codec"; import { AmountString } from "./talerTypes"; +import { codec } from ".."; /** * Response for the create reserve request to the wallet. @@ -164,6 +167,20 @@ export interface BalancesResponse { balances: Balance[]; } +export const codecForBalance = (): Codec => + makeCodecForObject() + .property("available", codecForString) + .property("hasPendingTransactions", codecForBoolean) + .property("pendingIncoming", codecForString) + .property("pendingOutgoing", codecForString) + .property("requiresUserInput", codecForBoolean) + .build("Balance"); + +export const codecForBalancesResponse = (): Codec => + makeCodecForObject() + .property("balances", makeCodecForList(codecForBalance())) + .build("BalancesResponse"); + /** * For terseness. */ diff --git a/packages/taler-wallet-core/src/util/http.ts b/packages/taler-wallet-core/src/util/http.ts index ad9f0293c..72de2ed1d 100644 --- a/packages/taler-wallet-core/src/util/http.ts +++ b/packages/taler-wallet-core/src/util/http.ts @@ -34,6 +34,7 @@ const logger = new Logger("http.ts"); */ export interface HttpResponse { requestUrl: string; + requestMethod: string; status: number; headers: Headers; json(): Promise; @@ -118,6 +119,8 @@ export async function readSuccessResponseJsonOrErrorCode( "Error response did not contain error code", { requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, + httpStatusCode: httpResponse.status, }, ), ); @@ -188,7 +191,9 @@ export async function readSuccessResponseTextOrErrorCode( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, "Error response did not contain error code", { + httpStatusCode: httpResponse.status, requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, }, ), ); @@ -217,7 +222,9 @@ export async function checkSuccessResponseOrThrow( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, "Error response did not contain error code", { + httpStatusCode: httpResponse.status, requestUrl: httpResponse.requestUrl, + requestMethod: httpResponse.requestMethod, }, ), ); diff --git a/packages/taler-wallet-core/src/util/talerconfig-test.ts b/packages/taler-wallet-core/src/util/talerconfig-test.ts new file mode 100644 index 000000000..71359fd38 --- /dev/null +++ b/packages/taler-wallet-core/src/util/talerconfig-test.ts @@ -0,0 +1,124 @@ +/* + 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 test from "ava"; +import { pathsub, Configuration } from "./talerconfig"; + +test("pathsub", (t) => { + t.assert("foo" === pathsub("foo", () => undefined)); + + t.assert("fo${bla}o" === pathsub("fo${bla}o", () => undefined)); + + const d: Record = { + w: "world", + f: "foo", + "1foo": "x", + "foo_bar": "quux", + }; + + t.is( + pathsub("hello ${w}!", (v) => d[v]), + "hello world!", + ); + + t.is( + pathsub("hello ${w} ${w}!", (v) => d[v]), + "hello world world!", + ); + + t.is( + pathsub("hello ${x:-blabla}!", (v) => d[v]), + "hello blabla!", + ); + + // No braces + t.is( + pathsub("hello $w!", (v) => d[v]), + "hello world!", + ); + t.is( + pathsub("hello $foo!", (v) => d[v]), + "hello $foo!", + ); + t.is( + pathsub("hello $1foo!", (v) => d[v]), + "hello $1foo!", + ); + t.is( + pathsub("hello $$ world!", (v) => d[v]), + "hello $$ world!", + ); + t.is( + pathsub("hello $$ world!", (v) => d[v]), + "hello $$ world!", + ); + + t.is( + pathsub("hello $foo_bar!", (v) => d[v]), + "hello quux!", + ); + + // Recursive lookup in default + t.is( + pathsub("hello ${x:-${w}}!", (v) => d[v]), + "hello world!", + ); + + // No variables in variable name part + t.is( + pathsub("hello ${${w}:-x}!", (v) => d[v]), + "hello ${${w}:-x}!", + ); + + // Missing closing brace + t.is( + pathsub("hello ${w!", (v) => d[v]), + "hello ${w!", + ); +}); + +test("path expansion", (t) => { + const config = new Configuration(); + config.setString("paths", "taler_home", "foo/bar"); + config.setString( + "paths", + "taler_data_home", + "$TALER_HOME/.local/share/taler/", + ); + config.setString( + "exchange", + "master_priv_file", + "${TALER_DATA_HOME}/exchange/offline-keys/master.priv", + ); + t.is( + config.getPath("exchange", "MaStER_priv_file").required(), + "foo/bar/.local/share/taler//exchange/offline-keys/master.priv", + ); +}); + +test("recursive path resolution", (t) => { + console.log("recursive test"); + const config = new Configuration(); + config.setString("paths", "a", "x${b}"); + config.setString("paths", "b", "y${a}"); + config.setString("foo", "x", "z${a}"); + t.throws(() => { + config.getPath("foo", "a").required(); + }); +}); diff --git a/packages/taler-wallet-core/src/util/talerconfig.ts b/packages/taler-wallet-core/src/util/talerconfig.ts index ec08c352f..61bb6d206 100644 --- a/packages/taler-wallet-core/src/util/talerconfig.ts +++ b/packages/taler-wallet-core/src/util/talerconfig.ts @@ -25,6 +25,8 @@ */ 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 +58,89 @@ export class ConfigValue { } } +/** + * Shell-style path substitution. + * + * Supported patterns: + * "$x" (look up "x") + * "${x}" (look up "x") + * "${x:-y}" (look up "x", fall back to expanded y) + */ +export function pathsub( + x: string, + lookup: (s: string, depth: number) => string | undefined, + depth = 0, +): string { + if (depth >= 10) { + throw Error("recursion in path substitution"); + } + let s = x; + let l = 0; + while (l < s.length) { + if (s[l] === "$") { + if (s[l + 1] === "{") { + let depth = 1; + const start = l; + let p = start + 2; + let insideNamePart = true; + let hasDefault = false; + for (; p < s.length; p++) { + if (s[p] == "}") { + insideNamePart = false; + depth--; + } else if (s[p] === "$" && s[p + 1] === "{") { + insideNamePart = false; + depth++; + } + if (insideNamePart && s[p] === ":" && s[p + 1] === "-") { + hasDefault = true; + } + if (depth == 0) { + break; + } + } + if (depth == 0) { + const inner = s.slice(start + 2, p); + let varname: string; + let defaultValue: string | undefined; + if (hasDefault) { + [varname, defaultValue] = inner.split(":-", 2); + } else { + varname = inner; + defaultValue = undefined; + } + + const r = lookup(inner, depth + 1); + if (r !== undefined) { + s = s.substr(0, start) + r + s.substr(p + 1); + l = start + r.length; + continue; + } else if (defaultValue !== undefined) { + const resolvedDefault = pathsub(defaultValue, lookup, depth + 1); + s = s.substr(0, start) + resolvedDefault + s.substr(p + 1); + l = start + resolvedDefault.length; + continue; + } + } + l = p; + continue; + } else { + const m = /^[a-zA-Z-_][a-zA-Z0-9-_]*/.exec(s.substring(l + 1)); + if (m && m[0]) { + const r = lookup(m[0], depth + 1); + if (r !== undefined) { + s = s.substr(0, l) + r + s.substr(l + 1 + m[0].length); + l = l + r.length; + continue; + } + } + } + } + l++; + } + return s; +} + export class Configuration { private sectionMap: SectionMap = {}; @@ -69,7 +154,6 @@ export class Configuration { const lines = s.split("\n"); for (const line of lines) { - console.log("parsing line", JSON.stringify(line)); if (reEmptyLine.test(line)) { continue; } @@ -79,15 +163,15 @@ export class Configuration { const secMatch = line.match(reSection); if (secMatch) { currentSection = secMatch[1]; - console.log("setting section to", currentSection); continue; } if (currentSection === undefined) { throw Error("invalid configuration, expected section header"); } + currentSection = currentSection.toUpperCase(); const paramMatch = line.match(reParam); if (paramMatch) { - const optName = paramMatch[1]; + const optName = paramMatch[1].toUpperCase(); let val = paramMatch[2]; if (val.startsWith('"') && val.endsWith('"')) { val = val.slice(1, val.length - 1); @@ -102,13 +186,44 @@ export class Configuration { "invalid configuration, expected section header or option assignment", ); } + } - console.log("parsed config", JSON.stringify(this.sectionMap, undefined, 2)); + setString(section: string, option: string, value: string): void { + const secNorm = section.toUpperCase(); + const sec = this.sectionMap[secNorm] ?? (this.sectionMap[secNorm] = {}); + sec[option.toUpperCase()] = value; } getString(section: string, option: string): ConfigValue { - const val = (this.sectionMap[section] ?? {})[option]; - return new ConfigValue(section, option, val, (x) => x); + const secNorm = section.toUpperCase(); + const optNorm = option.toUpperCase(); + const val = (this.sectionMap[section] ?? {})[optNorm]; + return new ConfigValue(secNorm, optNorm, val, (x) => x); + } + + getPath(section: string, option: string): ConfigValue { + const secNorm = section.toUpperCase(); + const optNorm = option.toUpperCase(); + const val = (this.sectionMap[secNorm] ?? {})[optNorm]; + return new ConfigValue(secNorm, optNorm, val, (x) => + pathsub(x, (v, d) => this.lookupVariable(v, d + 1)), + ); + } + + lookupVariable(x: string, depth: number = 0): string | undefined { + console.log("looking up", x); + // We loop up options in PATHS in upper case, as option names + // are case insensitive + const val = (this.sectionMap["PATHS"] ?? {})[x.toUpperCase()]; + if (val !== undefined) { + return pathsub(val, (v, d) => this.lookupVariable(v, d), depth); + } + // Environment variables can be case sensitive, respect that. + const envVal = process.env[x]; + if (envVal !== undefined) { + return envVal; + } + return; } getAmount(section: string, option: string): ConfigValue { @@ -117,4 +232,28 @@ export class Configuration { Amounts.parseOrThrow(x), ); } + + static load(filename: string): Configuration { + const s = fs.readFileSync(filename, "utf-8"); + const cfg = new Configuration(); + cfg.loadFromString(s); + return cfg; + } + + write(filename: string): void { + let s = ""; + for (const sectionName of Object.keys(this.sectionMap)) { + s += `[${sectionName}]\n`; + for (const optionName of Object.keys( + this.sectionMap[sectionName] ?? {}, + )) { + const val = this.sectionMap[sectionName][optionName]; + if (val !== undefined) { + s += `${optionName} = ${val}\n`; + } + } + s += "\n"; + } + fs.writeFileSync(filename, s); + } } diff --git a/packages/taler-wallet-core/src/util/timer.ts b/packages/taler-wallet-core/src/util/timer.ts index 8eab1399c..d652fdcda 100644 --- a/packages/taler-wallet-core/src/util/timer.ts +++ b/packages/taler-wallet-core/src/util/timer.ts @@ -34,6 +34,12 @@ const logger = new Logger("timer.ts"); */ export interface TimerHandle { clear(): void; + + /** + * Make sure the event loop exits when the timer is the + * only event left. Has no effect in the browser. + */ + unref(): void; } class IntervalHandle { @@ -42,6 +48,16 @@ class IntervalHandle { clear(): void { clearInterval(this.h); } + + /** + * Make sure the event loop exits when the timer is the + * only event left. Has no effect in the browser. + */ + unref(): void { + if (typeof this.h === "object") { + this.h.unref(); + } + } } class TimeoutHandle { @@ -50,6 +66,16 @@ class TimeoutHandle { clear(): void { clearTimeout(this.h); } + + /** + * Make sure the event loop exits when the timer is the + * only event left. Has no effect in the browser. + */ + unref(): void { + if (typeof this.h === "object") { + this.h.unref(); + } + } } /** @@ -92,6 +118,10 @@ const nullTimerHandle = { // do nothing return; }, + unref() { + // do nothing + return; + } }; /** @@ -141,6 +171,9 @@ export class TimerGroup { h.clear(); delete tm[myId]; }, + unref() { + h.unref(); + } }; } @@ -160,6 +193,9 @@ export class TimerGroup { h.clear(); delete tm[myId]; }, + unref() { + h.unref(); + } }; } } diff --git a/packages/taler-wallet-webextension/src/browserHttpLib.ts b/packages/taler-wallet-webextension/src/browserHttpLib.ts index 2782e4a14..42c0c4f00 100644 --- a/packages/taler-wallet-webextension/src/browserHttpLib.ts +++ b/packages/taler-wallet-webextension/src/browserHttpLib.ts @@ -102,6 +102,7 @@ export class BrowserHttpLib implements httpLib.HttpRequestLibrary { requestUrl: url, status: myRequest.status, headers: headerMap, + requestMethod: method, json: makeJson, text: async () => myRequest.responseText, }; @@ -112,7 +113,7 @@ export class BrowserHttpLib implements httpLib.HttpRequestLibrary { } get(url: string, opt?: httpLib.HttpRequestOptions): Promise { - return this.req("get", url, undefined, opt); + return this.req("GET", url, undefined, opt); } postJson( @@ -120,7 +121,7 @@ export class BrowserHttpLib implements httpLib.HttpRequestLibrary { body: unknown, opt?: httpLib.HttpRequestOptions, ): Promise { - return this.req("post", url, JSON.stringify(body), opt); + return this.req("POST", url, JSON.stringify(body), opt); } stop(): void { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 971d9d552..63b2ab80f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,28 @@ importers: specifiers: '@types/node': ^11.12.0 typescript: ^3.3.4000 + packages/taler-integrationtests: + dependencies: + axios: 0.19.2 + taler-wallet-core: 'link:../taler-wallet-core' + tslib: 2.0.0 + typescript: 3.9.7 + devDependencies: + '@ava/typescript': 1.1.1 + ava: 3.11.1 + esm: 3.2.25 + source-map-support: 0.5.19 + ts-node: 8.10.2_typescript@3.9.7 + specifiers: + '@ava/typescript': ^1.1.1 + ava: ^3.11.1 + axios: ^0.19.2 + esm: ^3.2.25 + source-map-support: ^0.5.19 + taler-wallet-core: 'workspace:*' + ts-node: ^8.10.2 + tslib: ^2.0.0 + typescript: ^3.9.7 packages/taler-wallet-android: dependencies: taler-wallet-core: 'link:../taler-wallet-core' @@ -100,6 +122,7 @@ importers: '@typescript-eslint/eslint-plugin': 3.7.1_98f5354ad0bbc327ab4925c12674a6b1 '@typescript-eslint/parser': 3.7.1_eslint@7.6.0+typescript@3.9.7 ava: 3.11.0 + dts-bundle-generator: 5.3.0 eslint: 7.6.0 eslint-config-airbnb-typescript: 8.0.2_de36c6f68d63a4142de06a31bab9d790 eslint-plugin-import: 2.22.0_eslint@7.6.0 @@ -115,6 +138,7 @@ importers: prettier: 2.0.5 rimraf: 3.0.2 rollup: 2.23.0 + rollup-plugin-sourcemaps: 0.6.2_1bb4f16ce5b550396581a296af208cfa source-map-resolve: 0.6.0 structured-clone: 0.2.2 typedoc: 0.17.8_typescript@3.9.7 @@ -127,6 +151,7 @@ importers: ava: ^3.10.1 axios: ^0.19.2 big-integer: ^1.6.48 + dts-bundle-generator: ^5.3.0 eslint: ^7.4.0 eslint-config-airbnb-typescript: ^8.0.2 eslint-plugin-import: ^2.22.0 @@ -143,6 +168,7 @@ importers: prettier: ^2.0.5 rimraf: ^3.0.2 rollup: ^2.23.0 + rollup-plugin-sourcemaps: ^0.6.2 source-map-resolve: ^0.6.0 source-map-support: ^0.5.19 structured-clone: ^0.2.2 @@ -861,6 +887,10 @@ packages: dev: true resolution: integrity: sha1-+cjBN1fMHde8N5rHeyxipcKGjEA= + /arg/4.1.3: + dev: true + resolution: + integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== /argparse/1.0.10: dependencies: sprintf-js: 1.0.3 @@ -1032,6 +1062,69 @@ packages: hasBin: true resolution: integrity: sha512-y5U8BGeSRjs/OypsC4CJxr+L1KtLKU5kUyHr5hcghXn7HNr2f4LE/4gvl0Q5lNkLX1obdRW1oODphNdU/glwmA== + /ava/3.11.1: + dependencies: + '@concordance/react': 2.0.0 + acorn: 7.3.1 + acorn-walk: 7.2.0 + ansi-styles: 4.2.1 + arrgv: 1.0.2 + arrify: 2.0.1 + callsites: 3.1.0 + chalk: 4.1.0 + chokidar: 3.4.1 + chunkd: 2.0.1 + ci-info: 2.0.0 + ci-parallel-vars: 1.0.1 + clean-yaml-object: 0.1.0 + cli-cursor: 3.1.0 + cli-truncate: 2.1.0 + code-excerpt: 3.0.0 + common-path-prefix: 3.0.0 + concordance: 5.0.0 + convert-source-map: 1.7.0 + currently-unhandled: 0.4.1 + debug: 4.1.1 + del: 5.1.0 + emittery: 0.7.1 + equal-length: 1.0.1 + figures: 3.2.0 + globby: 11.0.1 + ignore-by-default: 2.0.0 + import-local: 3.0.2 + indent-string: 4.0.0 + is-error: 2.2.2 + is-plain-object: 4.1.1 + is-promise: 4.0.0 + lodash: 4.17.19 + matcher: 3.0.0 + md5-hex: 3.0.1 + mem: 6.1.0 + ms: 2.1.2 + ora: 4.0.5 + p-map: 4.0.0 + picomatch: 2.2.2 + pkg-conf: 3.1.0 + plur: 4.0.0 + pretty-ms: 7.0.0 + read-pkg: 5.2.0 + resolve-cwd: 3.0.0 + slash: 3.0.0 + source-map-support: 0.5.19 + stack-utils: 2.0.2 + strip-ansi: 6.0.0 + supertap: 1.0.0 + temp-dir: 2.0.0 + trim-off-newlines: 1.0.1 + update-notifier: 4.1.0 + write-file-atomic: 3.0.3 + yargs: 15.4.1 + dev: true + engines: + node: '>=10.18.0 <11 || >=12.14.0 <12.17.0 || >=12.17.0 <13 || >=14.0.0' + hasBin: true + resolution: + integrity: sha512-yGPD0msa5Qronw7GHDNlLaB7oU5zryYtXeuvny40YV6TMskSghqK7Ky3NisM/sr+aqI3DY7sfmORx8dIWQgMoQ== /axe-core/3.5.5: dev: true engines: @@ -1541,6 +1634,12 @@ packages: node: '>=8' resolution: integrity: sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA== + /diff/4.0.2: + dev: true + engines: + node: '>=0.3.1' + resolution: + integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== /dir-glob/3.0.1: dependencies: path-type: 4.0.0 @@ -1617,6 +1716,16 @@ packages: node: '>=8' resolution: integrity: sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A== + /dts-bundle-generator/5.3.0: + dependencies: + typescript: 3.9.7 + yargs: 15.4.1 + dev: true + engines: + node: '>=12.0.0' + hasBin: true + resolution: + integrity: sha512-PevcqtUQDsVs1FoXNEEvBgXWP2pNXT/booL+ufNcKSynEP8l01ebI9MgamECljThi+MHyjxYEbwGx+95TvigMQ== /duplexer3/0.1.4: dev: true resolution: @@ -3070,6 +3179,10 @@ packages: node: '>=8' resolution: integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + /make-error/1.3.6: + dev: true + resolution: + integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== /map-age-cleaner/0.1.3: dependencies: p-defer: 1.0.0 @@ -3353,14 +3466,14 @@ packages: dev: true resolution: integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E= - /onetime/5.1.0: + /onetime/5.1.1: dependencies: mimic-fn: 2.1.0 dev: true engines: node: '>=6' resolution: - integrity: sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q== + integrity: sha512-ZpZpjcJeugQfWsfyQlshVoowIIQ1qBGSVll4rfDq6JJVO//fesjoX808hXWfBjY+ROZgpKDI5TRSRBSoJiZ8eg== /optionator/0.9.1: dependencies: deep-is: 0.1.3 @@ -3944,7 +4057,7 @@ packages: integrity: sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec= /restore-cursor/3.1.0: dependencies: - onetime: 5.1.0 + onetime: 5.1.1 signal-exit: 3.0.3 dev: true engines: @@ -4449,6 +4562,22 @@ packages: node: '>=0.10.0' resolution: integrity: sha1-n5up2e+odkw4dpi8v+sshI8RrbM= + /ts-node/8.10.2_typescript@3.9.7: + dependencies: + arg: 4.1.3 + diff: 4.0.2 + make-error: 1.3.6 + source-map-support: 0.5.19 + typescript: 3.9.7 + yn: 3.1.1 + dev: true + engines: + node: '>=6.0.0' + hasBin: true + peerDependencies: + typescript: '>=2.7' + resolution: + integrity: sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA== /tsconfig-paths/3.9.0: dependencies: '@types/json5': 0.0.29 @@ -4539,7 +4668,6 @@ packages: resolution: integrity: sha512-/OyrHCJ8jtzu+QZ+771YaxQ9s4g5Z3XsQE3Ma7q+BL392xxBn4UMvvCdVnqKC2T/dz03/VXSLVKOP3lHmDdc/w== /typescript/3.9.7: - dev: true engines: node: '>=4.2.0' hasBin: true @@ -4736,3 +4864,9 @@ packages: node: '>=8' resolution: integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + /yn/3.1.1: + dev: true + engines: + node: '>=6' + resolution: + integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==