summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-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
-rw-r--r--packages/taler-wallet-android/src/index.ts1
-rwxr-xr-xpackages/taler-wallet-cli/bin/taler-wallet-cli2
-rw-r--r--packages/taler-wallet-cli/src/index.ts15
-rw-r--r--packages/taler-wallet-core/package.json14
-rw-r--r--packages/taler-wallet-core/rollup.config.js7
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts12
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts19
-rw-r--r--packages/taler-wallet-core/src/headless/NodeHttpLib.ts10
-rw-r--r--packages/taler-wallet-core/src/index.ts7
-rw-r--r--packages/taler-wallet-core/src/operations/exchanges.ts10
-rw-r--r--packages/taler-wallet-core/src/operations/pay.ts2
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.ts2
-rw-r--r--packages/taler-wallet-core/src/types/walletTypes.ts17
-rw-r--r--packages/taler-wallet-core/src/util/http.ts7
-rw-r--r--packages/taler-wallet-core/src/util/talerconfig-test.ts124
-rw-r--r--packages/taler-wallet-core/src/util/talerconfig.ts151
-rw-r--r--packages/taler-wallet-core/src/util/timer.ts36
-rw-r--r--packages/taler-wallet-webextension/src/browserHttpLib.ts5
28 files changed, 2395 insertions, 29 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/**/*"]
+}
diff --git a/packages/taler-wallet-android/src/index.ts b/packages/taler-wallet-android/src/index.ts
index d0001e991..c949a4773 100644
--- a/packages/taler-wallet-android/src/index.ts
+++ b/packages/taler-wallet-android/src/index.ts
@@ -113,6 +113,7 @@ export class AndroidHttpLib implements httpLib.HttpRequestLibrary {
requestUrl: "",
headers,
status: msg.status,
+ requestMethod: "FIXME",
json: async () => JSON.parse(msg.responseText),
text: async () => msg.responseText,
};
diff --git a/packages/taler-wallet-cli/bin/taler-wallet-cli b/packages/taler-wallet-cli/bin/taler-wallet-cli
index 871514024..756de2027 100755
--- a/packages/taler-wallet-cli/bin/taler-wallet-cli
+++ b/packages/taler-wallet-cli/bin/taler-wallet-cli
@@ -4,4 +4,4 @@ try {
} catch (e) {
// Do nothing.
}
-require('../dist/taler-wallet-cli.js')
+require('../dist/taler-wallet-cli.js').walletCli.run();
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
index c8e517e53..ae5371ecc 100644
--- a/packages/taler-wallet-cli/src/index.ts
+++ b/packages/taler-wallet-cli/src/index.ts
@@ -34,6 +34,12 @@ import {
NodeHttpLib,
} from "taler-wallet-core";
import * as clk from "./clk";
+import { NodeThreadCryptoWorkerFactory } from "taler-wallet-core/lib/crypto/workers/nodeThreadWorker";
+import { CryptoApi } from "taler-wallet-core/lib/crypto/workers/cryptoApi";
+
+// This module also serves as the entry point for the crypto
+// thread worker, and thus must expose these two handlers.
+export { handleWorkerError, handleWorkerMessage } from "taler-wallet-core";
const logger = new Logger("taler-wallet-cli.ts");
@@ -109,7 +115,7 @@ function printVersion(): void {
process.exit(0);
}
-const walletCli = clk
+export const walletCli = clk
.program("wallet", {
help: "Command line interface for the GNU Taler wallet.",
})
@@ -637,4 +643,9 @@ testCli.subcommand("vectors", "vectors").action(async (args) => {
testvectors.printTestVectors();
});
-walletCli.run();
+testCli.subcommand("cryptoworker", "cryptoworker").action(async (args) => {
+ const workerFactory = new NodeThreadCryptoWorkerFactory();
+ const cryptoApi = new CryptoApi(workerFactory);
+ const res = await cryptoApi.hashString("foo");
+ console.log(res);
+});
diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json
index 20240bab4..68bf45d0a 100644
--- a/packages/taler-wallet-core/package.json
+++ b/packages/taler-wallet-core/package.json
@@ -34,25 +34,27 @@
"@typescript-eslint/eslint-plugin": "^3.6.1",
"@typescript-eslint/parser": "^3.6.1",
"ava": "^3.10.1",
+ "dts-bundle-generator": "^5.3.0",
"eslint": "^7.4.0",
"eslint-config-airbnb-typescript": "^8.0.2",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-react": "^7.20.3",
"eslint-plugin-react-hooks": "^4.0.8",
+ "esm": "^3.2.25",
"jed": "^1.1.1",
"moment": "^2.27.0",
"nyc": "^15.1.0",
"po2json": "^0.4.5",
"pogen": "workspace:*",
"prettier": "^2.0.5",
+ "rimraf": "^3.0.2",
+ "rollup": "^2.23.0",
+ "rollup-plugin-sourcemaps": "^0.6.2",
"source-map-resolve": "^0.6.0",
"structured-clone": "^0.2.2",
"typedoc": "^0.17.8",
- "typescript": "^3.9.7",
- "rollup": "^2.23.0",
- "esm": "^3.2.25",
- "rimraf": "^3.0.2"
+ "typescript": "^3.9.7"
},
"dependencies": {
"@types/node": "^14.0.27",
@@ -63,7 +65,9 @@
"tslib": "^2.0.0"
},
"ava": {
- "require": ["esm"],
+ "require": [
+ "esm"
+ ],
"files": [
"src/**/*-test.*"
],
diff --git a/packages/taler-wallet-core/rollup.config.js b/packages/taler-wallet-core/rollup.config.js
index 2f0a86b2a..bcc8e5b26 100644
--- a/packages/taler-wallet-core/rollup.config.js
+++ b/packages/taler-wallet-core/rollup.config.js
@@ -4,13 +4,14 @@ import nodeResolve from "@rollup/plugin-node-resolve";
import json from "@rollup/plugin-json";
import builtins from "builtin-modules";
import pkg from "./package.json";
+import sourcemaps from 'rollup-plugin-sourcemaps';
export default {
input: "lib/index.js",
output: {
file: pkg.main,
format: "cjs",
- sourcemap: false,
+ sourcemap: true,
},
external: builtins,
plugins: [
@@ -18,11 +19,13 @@ export default {
preferBuiltins: true,
}),
+ sourcemaps(),
+
commonjs({
include: [/node_modules/, /dist/],
extensions: [".js"],
ignoreGlobal: false,
- sourceMap: false,
+ sourceMap: true,
}),
json(),
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
index a272d5724..20d13a3f2 100644
--- a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
+++ b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
@@ -1,17 +1,17 @@
/*
- This file is part of TALER
+ This file is part of GNU Taler
(C) 2016 GNUnet e.V.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
@@ -46,6 +46,7 @@ import {
import * as timer from "../../util/timer";
import { Logger } from "../../util/logging";
+import { walletCoreApi } from "../..";
const logger = new Logger("cryptoApi.ts");
@@ -182,7 +183,7 @@ export class CryptoApi {
};
this.resetWorkerTimeout(ws);
work.startTime = timer.performanceNow();
- setTimeout(() => worker.postMessage(msg), 0);
+ timer.after(0, () => worker.postMessage(msg));
}
resetWorkerTimeout(ws: WorkerState): void {
@@ -198,6 +199,7 @@ export class CryptoApi {
}
};
ws.terminationTimerHandle = timer.after(15 * 1000, destroy);
+ //ws.terminationTimerHandle.unref();
}
handleWorkerError(ws: WorkerState, e: any): void {
diff --git a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
index 6c9dfc569..d4d858330 100644
--- a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
+++ b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
@@ -21,6 +21,9 @@ import { CryptoWorkerFactory } from "./cryptoApi";
import { CryptoWorker } from "./cryptoWorker";
import os from "os";
import { CryptoImplementation } from "./cryptoImplementation";
+import { Logger } from "../../util/logging";
+
+const logger = new Logger("nodeThreadWorker.ts");
const f = __filename;
@@ -37,16 +40,22 @@ const workerCode = `
try {
tw = require("${f}");
} catch (e) {
- console.log("could not load from ${f}");
+ console.warn("could not load from ${f}");
}
if (!tw) {
try {
tw = require("taler-wallet-android");
} catch (e) {
- console.log("could not load taler-wallet-android either");
+ console.warn("could not load taler-wallet-android either");
throw e;
}
}
+ if (typeof tw.handleWorkerMessage !== "function") {
+ throw Error("module loaded for crypto worker lacks handleWorkerMessage");
+ }
+ if (typeof tw.handleWorkerError !== "function") {
+ throw Error("module loaded for crypto worker lacks handleWorkerError");
+ }
parentPort.on("message", tw.handleWorkerMessage);
parentPort.on("error", tw.handleWorkerError);
`;
@@ -138,6 +147,9 @@ class NodeThreadCryptoWorker implements CryptoWorker {
constructor() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const worker_threads = require("worker_threads");
+
+ logger.trace("starting node crypto worker");
+
this.nodeWorker = new worker_threads.Worker(workerCode, { eval: true });
this.nodeWorker.on("error", (err: Error) => {
console.error("error in node worker:", err);
@@ -145,6 +157,9 @@ class NodeThreadCryptoWorker implements CryptoWorker {
this.onerror(err);
}
});
+ this.nodeWorker.on("exit", (err) => {
+ logger.trace(`worker exited with code ${err}`);
+ });
this.nodeWorker.on("message", (v: any) => {
if (this.onmessage) {
this.onmessage(v);
diff --git a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts b/packages/taler-wallet-core/src/headless/NodeHttpLib.ts
index d109c3b7c..59730ab30 100644
--- a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts
+++ b/packages/taler-wallet-core/src/headless/NodeHttpLib.ts
@@ -45,7 +45,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
}
private async req(
- method: "post" | "get",
+ method: "POST" | "GET",
url: string,
body: any,
opt?: HttpRequestOptions,
@@ -72,6 +72,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
{
httpStatusCode: resp.status,
requestUrl: url,
+ requestMethod: method,
},
),
);
@@ -88,6 +89,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
{
httpStatusCode: resp.status,
requestUrl: url,
+ requestMethod: method,
},
),
);
@@ -100,6 +102,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
{
httpStatusCode: resp.status,
requestUrl: url,
+ requestMethod: method,
},
),
);
@@ -112,6 +115,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
}
return {
requestUrl: url,
+ requestMethod: method,
headers,
status: resp.status,
text: async () => resp.data,
@@ -120,7 +124,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
}
async get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
- return this.req("get", url, undefined, opt);
+ return this.req("GET", url, undefined, opt);
}
async postJson(
@@ -128,6 +132,6 @@ export class NodeHttpLib implements HttpRequestLibrary {
body: any,
opt?: HttpRequestOptions,
): Promise<HttpResponse> {
- return this.req("post", url, body, opt);
+ return this.req("POST", url, body, opt);
}
}
diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts
index e70fc44f6..5c4961bd7 100644
--- a/packages/taler-wallet-core/src/index.ts
+++ b/packages/taler-wallet-core/src/index.ts
@@ -73,3 +73,10 @@ export * as i18n from "./i18n";
export * as nodeThreadWorker from "./crypto/workers/nodeThreadWorker";
export * as walletNotifications from "./types/notifications";
+
+export { Configuration } from "./util/talerconfig";
+
+export {
+ handleWorkerMessage,
+ handleWorkerError,
+} from "./crypto/workers/nodeThreadWorker";
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts
index ee49fddb5..8967173ca 100644
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ b/packages/taler-wallet-core/src/operations/exchanges.ts
@@ -112,6 +112,8 @@ async function updateExchangeWithKeys(
return;
}
+ logger.info("updating exchange /keys info");
+
const keysUrl = new URL("keys", baseUrl);
keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
@@ -121,6 +123,8 @@ async function updateExchangeWithKeys(
codecForExchangeKeysJson(),
);
+ logger.info("received /keys response");
+
if (exchangeKeysJson.denoms.length === 0) {
const opErr = makeErrorDetails(
TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
@@ -152,12 +156,16 @@ async function updateExchangeWithKeys(
const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value)
.currency;
+ logger.trace("processing denominations");
+
const newDenominations = await Promise.all(
exchangeKeysJson.denoms.map((d) =>
denominationRecordFromKeys(ws, baseUrl, d),
),
);
+ logger.trace("done with processing denominations");
+
const lastUpdateTimestamp = getTimestampNow();
const recoupGroupId: string | undefined = undefined;
@@ -241,6 +249,8 @@ async function updateExchangeWithKeys(
console.log("error while recouping coins:", e);
});
}
+
+ logger.trace("done updating exchange /keys");
}
async function updateExchangeFinalize(
diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts
index f23e326f8..0fa9e0a61 100644
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ b/packages/taler-wallet-core/src/operations/pay.ts
@@ -781,7 +781,7 @@ export async function submitPay(
}
const sessionId = purchase.lastSessionId;
- console.log("paying with session ID", sessionId);
+ logger.trace("paying with session ID", sessionId);
const payUrl = new URL(
`orders/${purchase.contractData.orderId}/pay`,
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts
index 3b0aa0095..9719772a7 100644
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ b/packages/taler-wallet-core/src/operations/withdraw.ts
@@ -712,7 +712,9 @@ export async function getWithdrawalDetailsForUri(
ws: InternalWalletState,
talerWithdrawUri: string,
): Promise<WithdrawUriInfoResponse> {
+ logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
const info = await getBankWithdrawalInfo(ws, talerWithdrawUri);
+ logger.trace(`got bank info`);
if (info.suggestedExchange) {
// FIXME: right now the exchange gets permanently added,
// we might want to only temporarily add it.
diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts b/packages/taler-wallet-core/src/types/walletTypes.ts
index 04f50f29a..83275a0cc 100644
--- a/packages/taler-wallet-core/src/types/walletTypes.ts
+++ b/packages/taler-wallet-core/src/types/walletTypes.ts
@@ -40,8 +40,11 @@ import {
codecForString,
makeCodecOptional,
Codec,
+ makeCodecForList,
+ codecForBoolean,
} from "../util/codec";
import { AmountString } from "./talerTypes";
+import { codec } from "..";
/**
* Response for the create reserve request to the wallet.
@@ -164,6 +167,20 @@ export interface BalancesResponse {
balances: Balance[];
}
+export const codecForBalance = (): Codec<Balance> =>
+ makeCodecForObject<Balance>()
+ .property("available", codecForString)
+ .property("hasPendingTransactions", codecForBoolean)
+ .property("pendingIncoming", codecForString)
+ .property("pendingOutgoing", codecForString)
+ .property("requiresUserInput", codecForBoolean)
+ .build("Balance");
+
+export const codecForBalancesResponse = (): Codec<BalancesResponse> =>
+ makeCodecForObject<BalancesResponse>()
+ .property("balances", makeCodecForList(codecForBalance()))
+ .build("BalancesResponse");
+
/**
* For terseness.
*/
diff --git a/packages/taler-wallet-core/src/util/http.ts b/packages/taler-wallet-core/src/util/http.ts
index ad9f0293c..72de2ed1d 100644
--- a/packages/taler-wallet-core/src/util/http.ts
+++ b/packages/taler-wallet-core/src/util/http.ts
@@ -34,6 +34,7 @@ const logger = new Logger("http.ts");
*/
export interface HttpResponse {
requestUrl: string;
+ requestMethod: string;
status: number;
headers: Headers;
json(): Promise<any>;
@@ -118,6 +119,8 @@ export async function readSuccessResponseJsonOrErrorCode<T>(
"Error response did not contain error code",
{
requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
},
),
);
@@ -188,7 +191,9 @@ export async function readSuccessResponseTextOrErrorCode<T>(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
"Error response did not contain error code",
{
+ httpStatusCode: httpResponse.status,
requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
},
),
);
@@ -217,7 +222,9 @@ export async function checkSuccessResponseOrThrow(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
"Error response did not contain error code",
{
+ httpStatusCode: httpResponse.status,
requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
},
),
);
diff --git a/packages/taler-wallet-core/src/util/talerconfig-test.ts b/packages/taler-wallet-core/src/util/talerconfig-test.ts
new file mode 100644
index 000000000..71359fd38
--- /dev/null
+++ b/packages/taler-wallet-core/src/util/talerconfig-test.ts
@@ -0,0 +1,124 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports
+ */
+import test from "ava";
+import { pathsub, Configuration } from "./talerconfig";
+
+test("pathsub", (t) => {
+ t.assert("foo" === pathsub("foo", () => undefined));
+
+ t.assert("fo${bla}o" === pathsub("fo${bla}o", () => undefined));
+
+ const d: Record<string, string> = {
+ w: "world",
+ f: "foo",
+ "1foo": "x",
+ "foo_bar": "quux",
+ };
+
+ t.is(
+ pathsub("hello ${w}!", (v) => d[v]),
+ "hello world!",
+ );
+
+ t.is(
+ pathsub("hello ${w} ${w}!", (v) => d[v]),
+ "hello world world!",
+ );
+
+ t.is(
+ pathsub("hello ${x:-blabla}!", (v) => d[v]),
+ "hello blabla!",
+ );
+
+ // No braces
+ t.is(
+ pathsub("hello $w!", (v) => d[v]),
+ "hello world!",
+ );
+ t.is(
+ pathsub("hello $foo!", (v) => d[v]),
+ "hello $foo!",
+ );
+ t.is(
+ pathsub("hello $1foo!", (v) => d[v]),
+ "hello $1foo!",
+ );
+ t.is(
+ pathsub("hello $$ world!", (v) => d[v]),
+ "hello $$ world!",
+ );
+ t.is(
+ pathsub("hello $$ world!", (v) => d[v]),
+ "hello $$ world!",
+ );
+
+ t.is(
+ pathsub("hello $foo_bar!", (v) => d[v]),
+ "hello quux!",
+ );
+
+ // Recursive lookup in default
+ t.is(
+ pathsub("hello ${x:-${w}}!", (v) => d[v]),
+ "hello world!",
+ );
+
+ // No variables in variable name part
+ t.is(
+ pathsub("hello ${${w}:-x}!", (v) => d[v]),
+ "hello ${${w}:-x}!",
+ );
+
+ // Missing closing brace
+ t.is(
+ pathsub("hello ${w!", (v) => d[v]),
+ "hello ${w!",
+ );
+});
+
+test("path expansion", (t) => {
+ const config = new Configuration();
+ config.setString("paths", "taler_home", "foo/bar");
+ config.setString(
+ "paths",
+ "taler_data_home",
+ "$TALER_HOME/.local/share/taler/",
+ );
+ config.setString(
+ "exchange",
+ "master_priv_file",
+ "${TALER_DATA_HOME}/exchange/offline-keys/master.priv",
+ );
+ t.is(
+ config.getPath("exchange", "MaStER_priv_file").required(),
+ "foo/bar/.local/share/taler//exchange/offline-keys/master.priv",
+ );
+});
+
+test("recursive path resolution", (t) => {
+ console.log("recursive test");
+ const config = new Configuration();
+ config.setString("paths", "a", "x${b}");
+ config.setString("paths", "b", "y${a}");
+ config.setString("foo", "x", "z${a}");
+ t.throws(() => {
+ config.getPath("foo", "a").required();
+ });
+});
diff --git a/packages/taler-wallet-core/src/util/talerconfig.ts b/packages/taler-wallet-core/src/util/talerconfig.ts
index ec08c352f..61bb6d206 100644
--- a/packages/taler-wallet-core/src/util/talerconfig.ts
+++ b/packages/taler-wallet-core/src/util/talerconfig.ts
@@ -25,6 +25,8 @@
*/
import { AmountJson } from "./amounts";
import * as Amounts from "./amounts";
+import fs from "fs";
+import { acceptExchangeTermsOfService } from "../operations/exchanges";
export class ConfigError extends Error {
constructor(message: string) {
@@ -56,6 +58,89 @@ export class ConfigValue<T> {
}
}
+/**
+ * Shell-style path substitution.
+ *
+ * Supported patterns:
+ * "$x" (look up "x")
+ * "${x}" (look up "x")
+ * "${x:-y}" (look up "x", fall back to expanded y)
+ */
+export function pathsub(
+ x: string,
+ lookup: (s: string, depth: number) => string | undefined,
+ depth = 0,
+): string {
+ if (depth >= 10) {
+ throw Error("recursion in path substitution");
+ }
+ let s = x;
+ let l = 0;
+ while (l < s.length) {
+ if (s[l] === "$") {
+ if (s[l + 1] === "{") {
+ let depth = 1;
+ const start = l;
+ let p = start + 2;
+ let insideNamePart = true;
+ let hasDefault = false;
+ for (; p < s.length; p++) {
+ if (s[p] == "}") {
+ insideNamePart = false;
+ depth--;
+ } else if (s[p] === "$" && s[p + 1] === "{") {
+ insideNamePart = false;
+ depth++;
+ }
+ if (insideNamePart && s[p] === ":" && s[p + 1] === "-") {
+ hasDefault = true;
+ }
+ if (depth == 0) {
+ break;
+ }
+ }
+ if (depth == 0) {
+ const inner = s.slice(start + 2, p);
+ let varname: string;
+ let defaultValue: string | undefined;
+ if (hasDefault) {
+ [varname, defaultValue] = inner.split(":-", 2);
+ } else {
+ varname = inner;
+ defaultValue = undefined;
+ }
+
+ const r = lookup(inner, depth + 1);
+ if (r !== undefined) {
+ s = s.substr(0, start) + r + s.substr(p + 1);
+ l = start + r.length;
+ continue;
+ } else if (defaultValue !== undefined) {
+ const resolvedDefault = pathsub(defaultValue, lookup, depth + 1);
+ s = s.substr(0, start) + resolvedDefault + s.substr(p + 1);
+ l = start + resolvedDefault.length;
+ continue;
+ }
+ }
+ l = p;
+ continue;
+ } else {
+ const m = /^[a-zA-Z-_][a-zA-Z0-9-_]*/.exec(s.substring(l + 1));
+ if (m && m[0]) {
+ const r = lookup(m[0], depth + 1);
+ if (r !== undefined) {
+ s = s.substr(0, l) + r + s.substr(l + 1 + m[0].length);
+ l = l + r.length;
+ continue;
+ }
+ }
+ }
+ }
+ l++;
+ }
+ return s;
+}
+
export class Configuration {
private sectionMap: SectionMap = {};
@@ -69,7 +154,6 @@ export class Configuration {
const lines = s.split("\n");
for (const line of lines) {
- console.log("parsing line", JSON.stringify(line));
if (reEmptyLine.test(line)) {
continue;
}
@@ -79,15 +163,15 @@ export class Configuration {
const secMatch = line.match(reSection);
if (secMatch) {
currentSection = secMatch[1];
- console.log("setting section to", currentSection);
continue;
}
if (currentSection === undefined) {
throw Error("invalid configuration, expected section header");
}
+ currentSection = currentSection.toUpperCase();
const paramMatch = line.match(reParam);
if (paramMatch) {
- const optName = paramMatch[1];
+ const optName = paramMatch[1].toUpperCase();
let val = paramMatch[2];
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, val.length - 1);
@@ -102,13 +186,44 @@ export class Configuration {
"invalid configuration, expected section header or option assignment",
);
}
+ }
- console.log("parsed config", JSON.stringify(this.sectionMap, undefined, 2));
+ setString(section: string, option: string, value: string): void {
+ const secNorm = section.toUpperCase();
+ const sec = this.sectionMap[secNorm] ?? (this.sectionMap[secNorm] = {});
+ sec[option.toUpperCase()] = value;
}
getString(section: string, option: string): ConfigValue<string> {
- const val = (this.sectionMap[section] ?? {})[option];
- return new ConfigValue(section, option, val, (x) => x);
+ const secNorm = section.toUpperCase();
+ const optNorm = option.toUpperCase();
+ const val = (this.sectionMap[section] ?? {})[optNorm];
+ return new ConfigValue(secNorm, optNorm, val, (x) => x);
+ }
+
+ getPath(section: string, option: string): ConfigValue<string> {
+ const secNorm = section.toUpperCase();
+ const optNorm = option.toUpperCase();
+ const val = (this.sectionMap[secNorm] ?? {})[optNorm];
+ return new ConfigValue(secNorm, optNorm, val, (x) =>
+ pathsub(x, (v, d) => this.lookupVariable(v, d + 1)),
+ );
+ }
+
+ lookupVariable(x: string, depth: number = 0): string | undefined {
+ console.log("looking up", x);
+ // We loop up options in PATHS in upper case, as option names
+ // are case insensitive
+ const val = (this.sectionMap["PATHS"] ?? {})[x.toUpperCase()];
+ if (val !== undefined) {
+ return pathsub(val, (v, d) => this.lookupVariable(v, d), depth);
+ }
+ // Environment variables can be case sensitive, respect that.
+ const envVal = process.env[x];
+ if (envVal !== undefined) {
+ return envVal;
+ }
+ return;
}
getAmount(section: string, option: string): ConfigValue<AmountJson> {
@@ -117,4 +232,28 @@ export class Configuration {
Amounts.parseOrThrow(x),
);
}
+
+ static load(filename: string): Configuration {
+ const s = fs.readFileSync(filename, "utf-8");
+ const cfg = new Configuration();
+ cfg.loadFromString(s);
+ return cfg;
+ }
+
+ write(filename: string): void {
+ let s = "";
+ for (const sectionName of Object.keys(this.sectionMap)) {
+ s += `[${sectionName}]\n`;
+ for (const optionName of Object.keys(
+ this.sectionMap[sectionName] ?? {},
+ )) {
+ const val = this.sectionMap[sectionName][optionName];
+ if (val !== undefined) {
+ s += `${optionName} = ${val}\n`;
+ }
+ }
+ s += "\n";
+ }
+ fs.writeFileSync(filename, s);
+ }
}
diff --git a/packages/taler-wallet-core/src/util/timer.ts b/packages/taler-wallet-core/src/util/timer.ts
index 8eab1399c..d652fdcda 100644
--- a/packages/taler-wallet-core/src/util/timer.ts
+++ b/packages/taler-wallet-core/src/util/timer.ts
@@ -34,6 +34,12 @@ const logger = new Logger("timer.ts");
*/
export interface TimerHandle {
clear(): void;
+
+ /**
+ * Make sure the event loop exits when the timer is the
+ * only event left. Has no effect in the browser.
+ */
+ unref(): void;
}
class IntervalHandle {
@@ -42,6 +48,16 @@ class IntervalHandle {
clear(): void {
clearInterval(this.h);
}
+
+ /**
+ * Make sure the event loop exits when the timer is the
+ * only event left. Has no effect in the browser.
+ */
+ unref(): void {
+ if (typeof this.h === "object") {
+ this.h.unref();
+ }
+ }
}
class TimeoutHandle {
@@ -50,6 +66,16 @@ class TimeoutHandle {
clear(): void {
clearTimeout(this.h);
}
+
+ /**
+ * Make sure the event loop exits when the timer is the
+ * only event left. Has no effect in the browser.
+ */
+ unref(): void {
+ if (typeof this.h === "object") {
+ this.h.unref();
+ }
+ }
}
/**
@@ -92,6 +118,10 @@ const nullTimerHandle = {
// do nothing
return;
},
+ unref() {
+ // do nothing
+ return;
+ }
};
/**
@@ -141,6 +171,9 @@ export class TimerGroup {
h.clear();
delete tm[myId];
},
+ unref() {
+ h.unref();
+ }
};
}
@@ -160,6 +193,9 @@ export class TimerGroup {
h.clear();
delete tm[myId];
},
+ unref() {
+ h.unref();
+ }
};
}
}
diff --git a/packages/taler-wallet-webextension/src/browserHttpLib.ts b/packages/taler-wallet-webextension/src/browserHttpLib.ts
index 2782e4a14..42c0c4f00 100644
--- a/packages/taler-wallet-webextension/src/browserHttpLib.ts
+++ b/packages/taler-wallet-webextension/src/browserHttpLib.ts
@@ -102,6 +102,7 @@ export class BrowserHttpLib implements httpLib.HttpRequestLibrary {
requestUrl: url,
status: myRequest.status,
headers: headerMap,
+ requestMethod: method,
json: makeJson,
text: async () => myRequest.responseText,
};
@@ -112,7 +113,7 @@ export class BrowserHttpLib implements httpLib.HttpRequestLibrary {
}
get(url: string, opt?: httpLib.HttpRequestOptions): Promise<httpLib.HttpResponse> {
- return this.req("get", url, undefined, opt);
+ return this.req("GET", url, undefined, opt);
}
postJson(
@@ -120,7 +121,7 @@ export class BrowserHttpLib implements httpLib.HttpRequestLibrary {
body: unknown,
opt?: httpLib.HttpRequestOptions,
): Promise<httpLib.HttpResponse> {
- return this.req("post", url, JSON.stringify(body), opt);
+ return this.req("POST", url, JSON.stringify(body), opt);
}
stop(): void {