diff options
author | Florian Dold <florian.dold@gmail.com> | 2020-08-06 00:30:36 +0530 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2020-08-06 00:30:36 +0530 |
commit | 82a2437c0967871d6b942105c98c3382978cad29 (patch) | |
tree | da803c3d4a58d9c691f5908b379791c8ee55cc37 /packages/taler-integrationtests | |
parent | a8f03d3dd1ad04abf7f569cb44933b6dce6713e7 (diff) |
towards integration tests with fault injection
Diffstat (limited to 'packages/taler-integrationtests')
-rw-r--r-- | packages/taler-integrationtests/package.json | 43 | ||||
-rw-r--r-- | packages/taler-integrationtests/src/faultInjection.ts | 222 | ||||
-rw-r--r-- | packages/taler-integrationtests/src/harness.ts | 907 | ||||
-rw-r--r-- | packages/taler-integrationtests/src/helpers.ts | 157 | ||||
-rw-r--r-- | packages/taler-integrationtests/src/merchantApiTypes.ts | 217 | ||||
-rw-r--r-- | packages/taler-integrationtests/src/test-payment-fault.ts | 194 | ||||
-rw-r--r-- | packages/taler-integrationtests/src/test-payment.ts | 80 | ||||
-rw-r--r-- | packages/taler-integrationtests/src/test-withdrawal.ts | 68 | ||||
-rwxr-xr-x | packages/taler-integrationtests/testrunner | 63 | ||||
-rw-r--r-- | packages/taler-integrationtests/tsconfig.json | 32 |
10 files changed, 1983 insertions, 0 deletions
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 <dold@taler.net>", + "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 <http://www.gnu.org/licenses/> + */ + +/** + * Fault injection proxy. + * + * @author Florian Dold <dold@taler.net> + */ + +/** + * 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<string, string | string[] | undefined>; + requestBody?: Buffer; + dropRequest: boolean; +} + +export interface FaultInjectionResponseContext { + request: FaultInjectionRequestContext; + statusCode: number; + responseHeaders: Record<string, string | string[] | undefined>; + 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 <http://www.gnu.org/licenses/> + */ + +/** + * Test harness for various GNU Taler components. + * Also provides a fault-injection proxy. + * + * @author Florian Dold <dold@taler.net> + */ + +/** + * 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<void> { + 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<string> { + 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<WaitResult>; + 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<WaitResult> { + return this.waitPromise; + } +} + +export function makeTempDir(): Promise<string> { + 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<void> { + 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<string, string | undefined>; +} + +export interface TalerConfig { + sections: Record<string, TalerConfigSection>; +} + +export interface DbInfo { + connStr: string; + dbname: string; +} + +export async function setupDb(gc: GlobalTestState): Promise<DbInfo> { + 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<BankService> { + 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<void> { + this.proc = this.globalTestState.spawnService( + `taler-bank-manage -c "${this.configFile}" serve-http`, + "bank", + ); + } + + async pingUntilAvailable(): Promise<void> { + 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<void> { + const url = `http://localhost:${this.bankConfig.httpPort}/testing/register`; + await axios.post(url, { + username, + password, + }); + } + + async createRandomBankUser(): Promise<BankUser> { + 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<WithdrawalOperationInfo> { + 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<void> { + 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<WithdrawalOperationInfo>() + .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<void> { + 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<void> { + 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<void> { + 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<void> { + 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<MerchantService> { + 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<void> { + 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<PostOrderResponse> { + 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<void> { + 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<void>) { + 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<string, unknown>, + ): Promise<walletCoreApi.CoreApiResponse> { + 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<void> { + const wdb = this.globalTestState.testDir + "/walletdb.json"; + await sh(`taler-wallet-cli --no-throttle --wallet-db ${wdb} run-until-done`); + } + + async runPending(): Promise<void> { + 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 <http://www.gnu.org/licenses/> + */ + +/** + * Helpers to create typical test environments. + * + * @author Florian Dold <dold@taler.net> + */ + +/** + * 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<SimpleTestEnvironment> { + 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<void> { + + 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 <http://www.gnu.org/licenses/> + */ + +/** + * Test harness for various GNU Taler components. + * Also provides a fault-injection proxy. + * + * @author Florian Dold <dold@taler.net> + */ + +/** + * 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<talerTypes.ContractTerms>; + + // 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<PostOrderResponse> => + codec + .makeCodecForObject<PostOrderResponse>() + .property("order_id", codec.codecForString) + .property("token", codec.makeCodecOptional(codec.codecForString)) + .build("PostOrderResponse"); + +export const codecForCheckPaymentPaidResponse = (): codec.Codec< + CheckPaymentPaidResponse +> => + codec + .makeCodecForObject<CheckPaymentPaidResponse>() + .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<CheckPaymentUnpaidResponse>() + .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<MerchantOrderPrivateStatusResponse>() + .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 <http://www.gnu.org/licenses/> + */ + +/** + * 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 <http://www.gnu.org/licenses/> + */ + +/** + * 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 <http://www.gnu.org/licenses/> + */ + +/** + * 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/**/*"] +} |