aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-integrationtests
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2020-08-06 00:30:36 +0530
committerFlorian Dold <florian.dold@gmail.com>2020-08-06 00:30:36 +0530
commit82a2437c0967871d6b942105c98c3382978cad29 (patch)
treeda803c3d4a58d9c691f5908b379791c8ee55cc37 /packages/taler-integrationtests
parenta8f03d3dd1ad04abf7f569cb44933b6dce6713e7 (diff)
towards integration tests with fault injection
Diffstat (limited to 'packages/taler-integrationtests')
-rw-r--r--packages/taler-integrationtests/package.json43
-rw-r--r--packages/taler-integrationtests/src/faultInjection.ts222
-rw-r--r--packages/taler-integrationtests/src/harness.ts907
-rw-r--r--packages/taler-integrationtests/src/helpers.ts157
-rw-r--r--packages/taler-integrationtests/src/merchantApiTypes.ts217
-rw-r--r--packages/taler-integrationtests/src/test-payment-fault.ts194
-rw-r--r--packages/taler-integrationtests/src/test-payment.ts80
-rw-r--r--packages/taler-integrationtests/src/test-withdrawal.ts68
-rwxr-xr-xpackages/taler-integrationtests/testrunner63
-rw-r--r--packages/taler-integrationtests/tsconfig.json32
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/**/*"]
+}