summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-cli
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2021-01-12 20:04:16 +0100
committerFlorian Dold <florian@dold.me>2021-01-12 20:04:16 +0100
commita5681579fbddb001f5b7118fe705c6643581c722 (patch)
treec8bd46e6bf7a5c97ee3db676eae9405bfdf4d2b2 /packages/taler-wallet-cli
parent6772c5479394cbdf404857f75263749a5c91bd41 (diff)
downloadwallet-core-a5681579fbddb001f5b7118fe705c6643581c722.tar.gz
wallet-core-a5681579fbddb001f5b7118fe705c6643581c722.tar.bz2
wallet-core-a5681579fbddb001f5b7118fe705c6643581c722.zip
make integration tests part of taler-wallet-cli
Diffstat (limited to 'packages/taler-wallet-cli')
-rw-r--r--packages/taler-wallet-cli/package.json2
-rw-r--r--packages/taler-wallet-cli/src/index.ts20
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/denomStructures.ts151
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/faultInjection.ts263
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/harness.ts1718
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/helpers.ts370
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/merchantApiTypes.ts304
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/scenario-prompt-payment.ts60
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-bank-api.ts136
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-claim-loop.ts81
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts249
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-fee-regression.ts204
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-merchant-longpolling.ts132
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-merchant-refund-api.ts284
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-pay-abort.ts199
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-pay-paid.ts206
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-payment-claim.ts104
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-payment-fault.ts209
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts103
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-payment-multiple.ts160
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-payment-transient.ts172
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-payment.ts53
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-paywall-flow.ts233
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-refund-auto.ts100
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-refund-gone.ts127
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-refund-incremental.ts186
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-refund.ts103
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-revocation.ts120
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-timetravel-autorefresh.ts203
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-timetravel-withdraw.ts90
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-tipping.ts127
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-wallettesting.ts87
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-withdrawal-abort-bank.ts67
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts71
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts78
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/testrunner.ts176
36 files changed, 6948 insertions, 0 deletions
diff --git a/packages/taler-wallet-cli/package.json b/packages/taler-wallet-cli/package.json
index 564cf7f4a..c3fa6e794 100644
--- a/packages/taler-wallet-cli/package.json
+++ b/packages/taler-wallet-cli/package.json
@@ -43,7 +43,9 @@
"typescript": "^4.0.5"
},
"dependencies": {
+ "@types/minimatch": "^3.0.3",
"axios": "^0.21.0",
+ "minimatch": "^3.0.4",
"source-map-support": "^0.5.19",
"taler-wallet-core": "workspace:*",
"tslib": "^2.0.3"
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
index 87a51f30d..e4f1ccb50 100644
--- a/packages/taler-wallet-cli/src/index.ts
+++ b/packages/taler-wallet-cli/src/index.ts
@@ -41,6 +41,7 @@ import {
} from "taler-wallet-core";
import * as clk from "./clk";
import { deepStrictEqual } from "assert";
+import { getTestInfo, runTests } from "./integrationtests/testrunner";
// This module also serves as the entry point for the crypto
// thread worker, and thus must expose these two handlers.
@@ -749,6 +750,25 @@ const testCli = walletCli.subcommand("testingArgs", "testing", {
help: "Subcommands for testing GNU Taler deployments.",
});
+testCli
+ .subcommand("listIntegrationtests", "list-integrationtests")
+ .action(async (args) => {
+ for (const t of getTestInfo()) {
+ console.log(t.name);
+ }
+ });
+
+testCli
+ .subcommand("runIntegrationtests", "run-integrationtests")
+ .maybeArgument("pattern", clk.STRING, {
+ help: "Glob pattern to select which tests to run",
+ })
+ .action(async (args) => {
+ await runTests({
+ include_pattern: args.runIntegrationtests.pattern,
+ });
+ });
+
testCli.subcommand("vectors", "vectors").action(async (args) => {
printTestVectors();
});
diff --git a/packages/taler-wallet-cli/src/integrationtests/denomStructures.ts b/packages/taler-wallet-cli/src/integrationtests/denomStructures.ts
new file mode 100644
index 000000000..5ab9aca00
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/denomStructures.ts
@@ -0,0 +1,151 @@
+/*
+ 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/>
+ */
+
+export 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,
+};
+
+export 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`,
+});
+
+export 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`,
+});
+
+export 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`,
+});
+
+export 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`,
+});
+
+export 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`,
+});
+
+export 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 const defaultCoinConfig = [
+ coin_ct1,
+ coin_ct10,
+ coin_u1,
+ coin_u2,
+ coin_u4,
+ coin_u8,
+ coin_u10,
+];
+
+const coinCheapCommon = (curr: string) => ({
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ rsaKeySize: 1024,
+ feeRefresh: `${curr}:0.2`,
+ feeRefund: `${curr}:0.2`,
+ feeWithdraw: `${curr}:0.2`,
+});
+
+export function makeNoFeeCoinConfig(curr: string): CoinConfig[] {
+ const cc: CoinConfig[] = [];
+
+ for (let i = 0; i < 16; i++) {
+ const ct = 2 ** i;
+
+ const unit = Math.floor(ct / 100);
+ const cent = ct % 100;
+
+ cc.push({
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ rsaKeySize: 1024,
+ name: `${curr}-u${i}`,
+ feeDeposit: `${curr}:0`,
+ feeRefresh: `${curr}:0`,
+ feeRefund: `${curr}:0`,
+ feeWithdraw: `${curr}:0`,
+ value: `${curr}:${unit}.${cent}`,
+ });
+ }
+
+ return cc;
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/faultInjection.ts b/packages/taler-wallet-cli/src/integrationtests/faultInjection.ts
new file mode 100644
index 000000000..a2d4836d9
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/faultInjection.ts
@@ -0,0 +1,263 @@
+/*
+ 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,
+ MerchantServiceInterface,
+ MerchantService,
+ PrivateOrderStatusQuery,
+} from "./harness";
+import {
+ PostOrderRequest,
+ PostOrderResponse,
+ MerchantOrderPrivateStatusResponse,
+} from "./merchantApiTypes";
+
+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://localhost:${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);
+ }
+
+ clearAllFaults() {
+ 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;
+ }
+}
+
+export class FaultInjectedMerchantService implements MerchantServiceInterface {
+ baseUrl: string;
+ port: number;
+ faultProxy: FaultProxy;
+
+ get name(): string {
+ return this.innerMerchant.name;
+ }
+
+ private innerMerchant: MerchantService;
+ private inboundPort: number;
+
+ constructor(
+ t: GlobalTestState,
+ m: MerchantService,
+ proxyInboundPort: number,
+ ) {
+ this.innerMerchant = m;
+ this.faultProxy = new FaultProxy(t, {
+ inboundPort: proxyInboundPort,
+ targetPort: m.port,
+ });
+ this.faultProxy.start();
+ this.inboundPort = proxyInboundPort;
+ }
+
+ makeInstanceBaseUrl(instanceName?: string | undefined): string {
+ const url = new URL(this.innerMerchant.makeInstanceBaseUrl(instanceName));
+ url.port = `${this.inboundPort}`;
+ return url.href;
+ }
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/harness.ts b/packages/taler-wallet-cli/src/integrationtests/harness.ts
new file mode 100644
index 000000000..108b78540
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/harness.ts
@@ -0,0 +1,1718 @@
+/*
+ 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 { deepStrictEqual } from "assert";
+import { ChildProcess, spawn } from "child_process";
+import {
+ Configuration,
+ AmountJson,
+ Amounts,
+ Codec,
+ buildCodecForObject,
+ codecForString,
+ Duration,
+ CoreApiResponse,
+ PreparePayResult,
+ PreparePayRequest,
+ codecForPreparePayResult,
+ OperationFailedError,
+ AddExchangeRequest,
+ ExchangesListRespose,
+ codecForExchangesListResponse,
+ GetWithdrawalDetailsForUriRequest,
+ WithdrawUriInfoResponse,
+ codecForWithdrawUriInfoResponse,
+ ConfirmPayRequest,
+ ConfirmPayResult,
+ codecForConfirmPayResult,
+ IntegrationTestArgs,
+ TestPayArgs,
+ BalancesResponse,
+ codecForBalancesResponse,
+ encodeCrock,
+ getRandomBytes,
+ EddsaKeyPair,
+ eddsaGetPublic,
+ createEddsaKeyPair,
+ TransactionsResponse,
+ codecForTransactionsResponse,
+ WithdrawTestBalanceRequest,
+ AmountString,
+ ApplyRefundRequest,
+ codecForApplyRefundResponse,
+ codecForAny,
+ CoinDumpJson,
+ ForceExchangeUpdateRequest,
+ ForceRefreshRequest,
+ PrepareTipResult,
+ PrepareTipRequest,
+ codecForPrepareTipResult,
+ AcceptTipRequest,
+ AbortPayWithRefundRequest,
+ handleWorkerError,
+} from "taler-wallet-core";
+import { URL } from "url";
+import axios, { AxiosError } from "axios";
+import {
+ codecForMerchantOrderPrivateStatusResponse,
+ codecForPostOrderResponse,
+ PostOrderRequest,
+ PostOrderResponse,
+ MerchantOrderPrivateStatusResponse,
+ TippingReserveStatus,
+ TipCreateConfirmation,
+ TipCreateRequest,
+} from "./merchantApiTypes";
+import { ApplyRefundResponse } from "taler-wallet-core";
+import { PendingOperationsResponse } from "taler-wallet-core";
+import { CoinConfig } from "./denomStructures";
+import { after } from "taler-wallet-core/src/util/timer";
+
+const exec = util.promisify(require("child_process").exec);
+
+export async function delayMs(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(
+ t: GlobalTestState,
+ logName: string,
+ command: string,
+): Promise<string> {
+ console.log("runing command", command);
+ return new Promise((resolve, reject) => {
+ const stdoutChunks: Buffer[] = [];
+ const proc = spawn(command, {
+ stdio: ["inherit", "pipe", "pipe"],
+ shell: true,
+ });
+ proc.stdout.on("data", (x) => {
+ if (x instanceof Buffer) {
+ stdoutChunks.push(x);
+ } else {
+ throw Error("unexpected data chunk type");
+ }
+ });
+ const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
+ const stderrLog = fs.createWriteStream(stderrLogFileName, {
+ flags: "a",
+ });
+ proc.stderr.pipe(stderrLog);
+ proc.on("exit", (code, signal) => {
+ console.log(`child process exited (${code} / ${signal})`);
+ 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"));
+ });
+ });
+}
+
+function shellescape(args: string[]) {
+ const ret = args.map((s) => {
+ if (/[^A-Za-z0-9_\/:=-]/.test(s)) {
+ s = "'" + s.replace(/'/g, "'\\''") + "'";
+ s = s.replace(/^(?:'')+/g, "").replace(/\\'''/g, "\\'");
+ }
+ return s;
+ });
+ return ret.join(" ");
+}
+
+/**
+ * Run a shell command, return stdout.
+ *
+ * Log stderr to a log file.
+ */
+export async function runCommand(
+ t: GlobalTestState,
+ logName: string,
+ command: string,
+ args: string[],
+): Promise<string> {
+ console.log("runing command", shellescape([command, ...args]));
+ return new Promise((resolve, reject) => {
+ const stdoutChunks: Buffer[] = [];
+ const proc = spawn(command, args, {
+ stdio: ["inherit", "pipe", "pipe"],
+ shell: false,
+ });
+ proc.stdout.on("data", (x) => {
+ if (x instanceof Buffer) {
+ stdoutChunks.push(x);
+ } else {
+ throw Error("unexpected data chunk type");
+ }
+ });
+ const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
+ const stderrLog = fs.createWriteStream(stderrLogFileName, {
+ flags: "a",
+ });
+ proc.stderr.pipe(stderrLog);
+ proc.on("exit", (code, signal) => {
+ console.log(`child process exited (${code} / ${signal})`);
+ 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 class GlobalTestParams {
+ testDir: string;
+}
+
+export class GlobalTestState {
+ testDir: string;
+ procs: ProcessWrapper[];
+ servers: http.Server[];
+ inShutdown: boolean = false;
+ constructor(params: GlobalTestParams) {
+ this.testDir = params.testDir;
+ this.procs = [];
+ this.servers = [];
+ }
+
+ async assertThrowsOperationErrorAsync(
+ block: () => Promise<void>,
+ ): Promise<OperationFailedError> {
+ try {
+ await block();
+ } catch (e) {
+ if (e instanceof OperationFailedError) {
+ return e;
+ }
+ throw Error(`expected OperationFailedError to be thrown, but got ${e}`);
+ }
+ throw Error(
+ `expected OperationFailedError to be thrown, but block finished without throwing`,
+ );
+ }
+
+ async assertThrowsAsync(block: () => Promise<void>): Promise<any> {
+ try {
+ await block();
+ } catch (e) {
+ return e;
+ }
+ throw Error(
+ `expected exception to be thrown, but block finished without throwing`,
+ );
+ }
+
+ assertAxiosError(e: any): asserts e is AxiosError {
+ return e.isAxiosError;
+ }
+
+ assertTrue(b: boolean): asserts b {
+ if (!b) {
+ throw Error("test assertion failed");
+ }
+ }
+
+ assertDeepEqual<T>(actual: any, expected: T): asserts actual is T {
+ deepStrictEqual(actual, expected);
+ }
+
+ assertAmountEquals(
+ amtActual: string | AmountJson,
+ amtExpected: string | AmountJson,
+ ): void {
+ if (Amounts.cmp(amtActual, amtExpected) != 0) {
+ throw Error(
+ `test assertion failed: expected ${Amounts.stringify(
+ amtExpected,
+ )} but got ${Amounts.stringify(amtActual)}`,
+ );
+ }
+ }
+
+ assertAmountLeq(a: string | AmountJson, b: string | AmountJson): void {
+ if (Amounts.cmp(a, b) > 0) {
+ throw Error(
+ `test assertion failed: expected ${Amounts.stringify(
+ a,
+ )} to be less or equal (leq) than ${Amounts.stringify(b)}`,
+ );
+ }
+ }
+
+ 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");
+ }
+ }
+ }
+
+ spawnService(
+ command: string,
+ args: string[],
+ logName: string,
+ ): ProcessWrapper {
+ console.log(
+ `spawning process (${logName}): ${shellescape([command, ...args])}`,
+ );
+ const proc = spawn(command, args, {
+ stdio: ["inherit", "pipe", "pipe"],
+ });
+ console.log(`spawned process (${logName}) with pid ${proc.pid}`);
+ proc.on("error", (err) => {
+ console.log(`could not start process (${command})`, err);
+ });
+ proc.on("exit", (code, signal) => {
+ console.log(`process ${logName} exited`);
+ });
+ 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 shutdown(): Promise<void> {
+ if (this.inShutdown) {
+ return;
+ }
+ this.inShutdown = true;
+ console.log("shutting down");
+ 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;
+ allowRegistrations: boolean;
+ maxDebt?: string;
+}
+
+function setPaths(config: Configuration, home: string) {
+ config.setString("paths", "taler_home", home);
+ config.setString("paths", "taler_runtime_dir", "$TALER_HOME/taler-runtime/");
+ 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}`);
+}
+
+async function pingProc(
+ proc: ProcessWrapper | undefined,
+ url: string,
+ serviceName: string,
+): Promise<void> {
+ if (!proc || proc.proc.exitCode !== null) {
+ throw Error(`service process ${serviceName} not started, can't ping`);
+ }
+ while (true) {
+ try {
+ console.log(`pinging ${serviceName}`);
+ const resp = await axios.get(url);
+ console.log(`service ${serviceName} available`);
+ return;
+ } catch (e) {
+ console.log(`service ${serviceName} not ready:`, e.toString());
+ await delayMs(1000);
+ }
+ if (!proc || proc.proc.exitCode !== null) {
+ throw Error(`service process ${serviceName} stopped unexpectedly`);
+ }
+ }
+}
+
+export interface ExchangeBankAccount {
+ accountName: string;
+ accountPassword: string;
+ accountPaytoUri: string;
+ wireGatewayApiBaseUrl: string;
+}
+
+export interface BankServiceInterface {
+ readonly baseUrl: string;
+ readonly port: number;
+}
+
+export enum CreditDebitIndicator {
+ Credit = "credit",
+ Debit = "debit",
+}
+
+export interface BankAccountBalanceResponse {
+ balance: {
+ amount: AmountString;
+ credit_debit_indicator: CreditDebitIndicator;
+ };
+}
+
+export namespace BankAccessApi {
+ export async function getAccountBalance(
+ bank: BankServiceInterface,
+ bankUser: BankUser,
+ ): Promise<BankAccountBalanceResponse> {
+ const url = new URL(`accounts/${bankUser.username}`, bank.baseUrl);
+ const resp = await axios.get(url.href, {
+ auth: bankUser,
+ });
+ return resp.data;
+ }
+
+ export async function createWithdrawalOperation(
+ bank: BankServiceInterface,
+ bankUser: BankUser,
+ amount: string,
+ ): Promise<WithdrawalOperationInfo> {
+ const url = new URL(
+ `accounts/${bankUser.username}/withdrawals`,
+ bank.baseUrl,
+ );
+ const resp = await axios.post(
+ url.href,
+ {
+ amount,
+ },
+ {
+ auth: bankUser,
+ },
+ );
+ return codecForWithdrawalOperationInfo().decode(resp.data);
+ }
+}
+
+export namespace BankApi {
+ export async function registerAccount(
+ bank: BankServiceInterface,
+ username: string,
+ password: string,
+ ): Promise<BankUser> {
+ const url = new URL("testing/register", bank.baseUrl);
+ await axios.post(url.href, {
+ username,
+ password,
+ });
+ return {
+ password,
+ username,
+ accountPaytoUri: `payto://x-taler-bank/localhost/${username}`,
+ };
+ }
+
+ export async function createRandomBankUser(
+ bank: BankServiceInterface,
+ ): Promise<BankUser> {
+ const username = "user-" + encodeCrock(getRandomBytes(10));
+ const password = "pw-" + encodeCrock(getRandomBytes(10));
+ return await registerAccount(bank, username, password);
+ }
+
+ export async function adminAddIncoming(
+ bank: BankServiceInterface,
+ params: {
+ exchangeBankAccount: ExchangeBankAccount;
+ amount: string;
+ reservePub: string;
+ debitAccountPayto: string;
+ },
+ ) {
+ const url = new URL(
+ `taler-wire-gateway/${params.exchangeBankAccount.accountName}/admin/add-incoming`,
+ bank.baseUrl,
+ );
+ await axios.post(
+ url.href,
+ {
+ amount: params.amount,
+ reserve_pub: params.reservePub,
+ debit_account: params.debitAccountPayto,
+ },
+ {
+ auth: {
+ username: params.exchangeBankAccount.accountName,
+ password: params.exchangeBankAccount.accountPassword,
+ },
+ },
+ );
+ }
+
+ export async function confirmWithdrawalOperation(
+ bank: BankServiceInterface,
+ bankUser: BankUser,
+ wopi: WithdrawalOperationInfo,
+ ): Promise<void> {
+ const url = new URL(
+ `accounts/${bankUser.username}/withdrawals/${wopi.withdrawal_id}/confirm`,
+ bank.baseUrl,
+ );
+ await axios.post(
+ url.href,
+ {},
+ {
+ auth: bankUser,
+ },
+ );
+ }
+
+ export async function abortWithdrawalOperation(
+ bank: BankServiceInterface,
+ bankUser: BankUser,
+ wopi: WithdrawalOperationInfo,
+ ): Promise<void> {
+ const url = new URL(
+ `accounts/${bankUser.username}/withdrawals/${wopi.withdrawal_id}/abort`,
+ bank.baseUrl,
+ );
+ await axios.post(
+ url.href,
+ {},
+ {
+ auth: bankUser,
+ },
+ );
+ }
+}
+
+export class BankService implements BankServiceInterface {
+ proc: ProcessWrapper | undefined;
+
+ static fromExistingConfig(gc: GlobalTestState): BankService {
+ const cfgFilename = gc.testDir + "/bank.conf";
+ console.log("reading bank config from", cfgFilename);
+ const config = Configuration.load(cfgFilename);
+ const bc: BankConfig = {
+ allowRegistrations: config
+ .getYesNo("bank", "allow_registrations")
+ .required(),
+ currency: config.getString("taler", "currency").required(),
+ database: config.getString("bank", "database").required(),
+ httpPort: config.getNumber("bank", "http_port").required(),
+ };
+ return new BankService(gc, bc, cfgFilename);
+ }
+
+ static async create(
+ gc: GlobalTestState,
+ bc: BankConfig,
+ ): 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", "serve", "http");
+ config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
+ config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`);
+ config.setString(
+ "bank",
+ "allow_registrations",
+ bc.allowRegistrations ? "yes" : "no",
+ );
+ const cfgFilename = gc.testDir + "/bank.conf";
+ config.write(cfgFilename);
+
+ await sh(
+ gc,
+ "taler-bank-manage_django",
+ `taler-bank-manage -c '${cfgFilename}' django migrate`,
+ );
+ await sh(
+ gc,
+ "taler-bank-manage_django",
+ `taler-bank-manage -c '${cfgFilename}' django provide_accounts`,
+ );
+
+ return new BankService(gc, bc, cfgFilename);
+ }
+
+ setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) {
+ const config = Configuration.load(this.configFile);
+ config.setString("bank", "suggested_exchange", e.baseUrl);
+ config.setString("bank", "suggested_exchange_payto", exchangePayto);
+ }
+
+ get baseUrl(): string {
+ return `http://localhost:${this.bankConfig.httpPort}/`;
+ }
+
+ async createExchangeAccount(
+ accountName: string,
+ password: string,
+ ): Promise<ExchangeBankAccount> {
+ await sh(
+ this.globalTestState,
+ "taler-bank-manage_django",
+ `taler-bank-manage -c '${this.configFile}' django add_bank_account ${accountName}`,
+ );
+ await sh(
+ this.globalTestState,
+ "taler-bank-manage_django",
+ `taler-bank-manage -c '${this.configFile}' django changepassword_unsafe ${accountName} ${password}`,
+ );
+ await sh(
+ this.globalTestState,
+ "taler-bank-manage_django",
+ `taler-bank-manage -c '${this.configFile}' django top_up ${accountName} ${this.bankConfig.currency}:100000`,
+ );
+ return {
+ accountName: accountName,
+ accountPassword: password,
+ accountPaytoUri: `payto://x-taler-bank/${accountName}`,
+ wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/taler-wire-gateway/${accountName}/`,
+ };
+ }
+
+ 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"],
+ "bank",
+ );
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `http://localhost:${this.bankConfig.httpPort}/config`;
+ await pingProc(this.proc, url, "bank");
+ }
+}
+
+export interface BankUser {
+ username: string;
+ password: string;
+ accountPaytoUri: string;
+}
+
+export interface WithdrawalOperationInfo {
+ withdrawal_id: string;
+ taler_withdraw_uri: string;
+}
+
+const codecForWithdrawalOperationInfo = (): Codec<WithdrawalOperationInfo> =>
+ buildCodecForObject<WithdrawalOperationInfo>()
+ .property("withdrawal_id", codecForString())
+ .property("taler_withdraw_uri", 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 fromExistingConfig(gc: GlobalTestState, exchangeName: string) {
+ const cfgFilename = gc.testDir + `/exchange-${exchangeName}.conf`;
+ const config = Configuration.load(cfgFilename);
+ const ec: ExchangeConfig = {
+ currency: config.getString("taler", "currency").required(),
+ database: config.getString("exchangedb-postgres", "config").required(),
+ httpPort: config.getNumber("exchange", "port").required(),
+ name: exchangeName,
+ roundUnit: config.getString("taler", "currency_round_unit").required(),
+ };
+ const privFile = config.getPath("exchange", "master_priv_file").required();
+ const eddsaPriv = fs.readFileSync(privFile);
+ const keyPair: EddsaKeyPair = {
+ eddsaPriv,
+ eddsaPub: eddsaGetPublic(eddsaPriv),
+ };
+ return new ExchangeService(gc, ec, cfgFilename, keyPair);
+ }
+
+ private currentTimetravel: Duration | undefined;
+
+ setTimetravel(t: Duration | undefined): void {
+ if (this.isRunning()) {
+ throw Error("can't set time travel while the exchange is running");
+ }
+ this.currentTimetravel = t;
+ }
+
+ private get timetravelArg(): string | undefined {
+ if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
+ // Convert to microseconds
+ return `--timetravel=+${this.currentTimetravel.d_ms * 1000}`;
+ }
+ return undefined;
+ }
+
+ /**
+ * Return an empty array if no time travel is set,
+ * and an array with the time travel command line argument
+ * otherwise.
+ */
+ private get timetravelArgArr(): string[] {
+ const tta = this.timetravelArg;
+ if (tta) {
+ return [tta];
+ }
+ return [];
+ }
+
+ async runWirewatchOnce() {
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-wirewatch-once`,
+ "taler-exchange-wirewatch",
+ [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+
+ async runAggregatorOnce() {
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-aggregator-once`,
+ "taler-exchange-aggregator",
+ [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+
+ 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-offline",
+ "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", "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");
+
+ config.setString("exchangedb-postgres", "config", e.database);
+
+ const exchangeMasterKey = createEddsaKeyPair();
+
+ config.setString(
+ "exchange",
+ "master_public_key",
+ encodeCrock(exchangeMasterKey.eddsaPub),
+ );
+
+ const masterPrivFile = config
+ .getPath("exchange-offline", "master_priv_file")
+ .required();
+
+ fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true });
+
+ fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv));
+
+ const cfgFilename = gc.testDir + `/exchange-${e.name}.conf`;
+ config.write(cfgFilename);
+ return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey);
+ }
+
+ addOfferedCoins(offeredCoins: ((curr: string) => CoinConfig)[]) {
+ const config = Configuration.load(this.configFilename);
+ offeredCoins.forEach((cc) =>
+ setCoin(config, cc(this.exchangeConfig.currency)),
+ );
+ config.write(this.configFilename);
+ }
+
+ addCoinConfigList(ccs: CoinConfig[]) {
+ const config = Configuration.load(this.configFilename);
+ ccs.forEach((cc) => setCoin(config, cc));
+ config.write(this.configFilename);
+ }
+
+ get masterPub() {
+ return encodeCrock(this.keyPair.eddsaPub);
+ }
+
+ get port() {
+ return this.exchangeConfig.httpPort;
+ }
+
+ async addBankAccount(
+ localName: string,
+ exchangeBankAccount: ExchangeBankAccount,
+ ): Promise<void> {
+ 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",
+ exchangeBankAccount.accountPaytoUri,
+ );
+ config.setString(`exchange-account-${localName}`, "enable_credit", "yes");
+ config.setString(`exchange-account-${localName}`, "enable_debit", "yes");
+ config.setString(
+ `exchange-account-${localName}`,
+ "wire_gateway_url",
+ exchangeBankAccount.wireGatewayApiBaseUrl,
+ );
+ config.setString(
+ `exchange-account-${localName}`,
+ "wire_gateway_auth_method",
+ "basic",
+ );
+ config.setString(
+ `exchange-account-${localName}`,
+ "username",
+ exchangeBankAccount.accountName,
+ );
+ config.setString(
+ `exchange-account-${localName}`,
+ "password",
+ exchangeBankAccount.accountPassword,
+ );
+ config.write(this.configFilename);
+ }
+
+ exchangeHttpProc: ProcessWrapper | undefined;
+ exchangeWirewatchProc: ProcessWrapper | undefined;
+
+ helperCryptoRsaProc: ProcessWrapper | undefined;
+ helperCryptoEddsaProc: ProcessWrapper | undefined;
+
+ constructor(
+ private globalState: GlobalTestState,
+ private exchangeConfig: ExchangeConfig,
+ private configFilename: string,
+ private keyPair: EddsaKeyPair,
+ ) {}
+
+ get name() {
+ return this.exchangeConfig.name;
+ }
+
+ get baseUrl() {
+ return `http://localhost:${this.exchangeConfig.httpPort}/`;
+ }
+
+ isRunning(): boolean {
+ return !!this.exchangeWirewatchProc || !!this.exchangeHttpProc;
+ }
+
+ async stop(): Promise<void> {
+ const wirewatch = this.exchangeWirewatchProc;
+ if (wirewatch) {
+ wirewatch.proc.kill("SIGTERM");
+ await wirewatch.wait();
+ this.exchangeWirewatchProc = undefined;
+ }
+ const httpd = this.exchangeHttpProc;
+ if (httpd) {
+ httpd.proc.kill("SIGTERM");
+ await httpd.wait();
+ this.exchangeHttpProc = undefined;
+ }
+ const cryptoRsa = this.helperCryptoRsaProc;
+ if (cryptoRsa) {
+ cryptoRsa.proc.kill("SIGTERM");
+ await cryptoRsa.wait();
+ this.helperCryptoRsaProc = undefined;
+ }
+ const cryptoEddsa = this.helperCryptoEddsaProc;
+ if (cryptoEddsa) {
+ cryptoEddsa.proc.kill("SIGTERM");
+ await cryptoEddsa.wait();
+ this.helperCryptoRsaProc = undefined;
+ }
+ }
+
+ /**
+ * Update keys signing the keys generated by the security module
+ * with the offline signing key.
+ */
+ async keyup(): Promise<void> {
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ ...this.timetravelArgArr,
+ "download",
+ "sign",
+ "upload",
+ ],
+ );
+
+ const accounts: string[] = [];
+
+ const config = Configuration.load(this.configFilename);
+ for (const sectionName of config.getSectionNames()) {
+ if (sectionName.startsWith("exchange-account")) {
+ accounts.push(config.getString(sectionName, "payto_uri").required());
+ }
+ }
+
+ console.log("configuring bank accounts", accounts);
+
+ for (const acc of accounts) {
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ ...this.timetravelArgArr,
+ "enable-account",
+ acc,
+ "upload",
+ ],
+ );
+ }
+
+ const year = new Date().getFullYear();
+ for (let i = year; i < year + 5; i++) {
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ ...this.timetravelArgArr,
+ "wire-fee",
+ `${i}`,
+ "x-taler-bank",
+ `${this.exchangeConfig.currency}:0.01`,
+ `${this.exchangeConfig.currency}:0.01`,
+ "upload",
+ ],
+ );
+ }
+ }
+
+ async revokeDenomination(denomPubHash: string) {
+ if (!this.isRunning()) {
+ throw Error("exchange must be running when revoking denominations");
+ }
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ ...this.timetravelArgArr,
+ "revoke-denomination",
+ denomPubHash,
+ "upload",
+ ],
+ );
+ }
+
+ async start(): Promise<void> {
+ if (this.isRunning()) {
+ throw Error("exchange is already running");
+ }
+ await sh(
+ this.globalState,
+ "exchange-dbinit",
+ `taler-exchange-dbinit -c "${this.configFilename}"`,
+ );
+
+ this.helperCryptoEddsaProc = this.globalState.spawnService(
+ "taler-helper-crypto-eddsa",
+ ["-c", this.configFilename, ...this.timetravelArgArr],
+ `exchange-crypto-eddsa-${this.name}`,
+ );
+
+ this.helperCryptoRsaProc = this.globalState.spawnService(
+ "taler-helper-crypto-rsa",
+ ["-c", this.configFilename, ...this.timetravelArgArr],
+ `exchange-crypto-rsa-${this.name}`,
+ );
+
+ this.exchangeWirewatchProc = this.globalState.spawnService(
+ "taler-exchange-wirewatch",
+ ["-c", this.configFilename, ...this.timetravelArgArr],
+ `exchange-wirewatch-${this.name}`,
+ );
+
+ this.exchangeHttpProc = this.globalState.spawnService(
+ "taler-exchange-httpd",
+ [
+ "-c",
+ this.configFilename,
+ "--num-threads",
+ "1",
+ ...this.timetravelArgArr,
+ ],
+ `exchange-httpd-${this.name}`,
+ );
+
+ await this.keyup();
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `http://localhost:${this.exchangeConfig.httpPort}/keys`;
+ await pingProc(this.exchangeHttpProc, url, `exchange (${this.name})`);
+ }
+}
+
+export interface MerchantConfig {
+ name: string;
+ currency: string;
+ httpPort: number;
+ database: string;
+}
+
+export interface PrivateOrderStatusQuery {
+ instance?: string;
+ orderId: string;
+ sessionId?: string;
+}
+
+export interface MerchantServiceInterface {
+ makeInstanceBaseUrl(instanceName?: string): string;
+ readonly port: number;
+ readonly name: string;
+}
+
+export namespace MerchantPrivateApi {
+ export async function createOrder(
+ merchantService: MerchantServiceInterface,
+ instanceName: string,
+ req: PostOrderRequest,
+ ): Promise<PostOrderResponse> {
+ const baseUrl = merchantService.makeInstanceBaseUrl(instanceName);
+ let url = new URL("private/orders", baseUrl);
+ const resp = await axios.post(url.href, req);
+ return codecForPostOrderResponse().decode(resp.data);
+ }
+
+ export async function queryPrivateOrderStatus(
+ merchantService: MerchantServiceInterface,
+ query: PrivateOrderStatusQuery,
+ ): Promise<MerchantOrderPrivateStatusResponse> {
+ const reqUrl = new URL(
+ `private/orders/${query.orderId}`,
+ merchantService.makeInstanceBaseUrl(query.instance),
+ );
+ if (query.sessionId) {
+ reqUrl.searchParams.set("session_id", query.sessionId);
+ }
+ const resp = await axios.get(reqUrl.href);
+ return codecForMerchantOrderPrivateStatusResponse().decode(resp.data);
+ }
+
+ export async function giveRefund(
+ merchantService: MerchantServiceInterface,
+ r: {
+ instance: string;
+ orderId: string;
+ amount: string;
+ justification: string;
+ },
+ ): Promise<{ talerRefundUri: string }> {
+ const reqUrl = new URL(
+ `private/orders/${r.orderId}/refund`,
+ merchantService.makeInstanceBaseUrl(r.instance),
+ );
+ const resp = await axios.post(reqUrl.href, {
+ refund: r.amount,
+ reason: r.justification,
+ });
+ return {
+ talerRefundUri: resp.data.taler_refund_uri,
+ };
+ }
+
+ export async function createTippingReserve(
+ merchantService: MerchantServiceInterface,
+ instance: string,
+ req: CreateMerchantTippingReserveRequest,
+ ): Promise<CreateMerchantTippingReserveConfirmation> {
+ const reqUrl = new URL(
+ `private/reserves`,
+ merchantService.makeInstanceBaseUrl(instance),
+ );
+ const resp = await axios.post(reqUrl.href, req);
+ // FIXME: validate
+ return resp.data;
+ }
+
+ export async function queryTippingReserves(
+ merchantService: MerchantServiceInterface,
+ instance: string,
+ ): Promise<TippingReserveStatus> {
+ const reqUrl = new URL(
+ `private/reserves`,
+ merchantService.makeInstanceBaseUrl(instance),
+ );
+ const resp = await axios.get(reqUrl.href);
+ // FIXME: validate
+ return resp.data;
+ }
+
+ export async function giveTip(
+ merchantService: MerchantServiceInterface,
+ instance: string,
+ req: TipCreateRequest,
+ ): Promise<TipCreateConfirmation> {
+ const reqUrl = new URL(
+ `private/tips`,
+ merchantService.makeInstanceBaseUrl(instance),
+ );
+ const resp = await axios.post(reqUrl.href, req);
+ // FIXME: validate
+ return resp.data;
+ }
+}
+
+export interface CreateMerchantTippingReserveRequest {
+ // Amount that the merchant promises to put into the reserve
+ initial_balance: AmountString;
+
+ // Exchange the merchant intends to use for tipping
+ exchange_url: string;
+
+ // Desired wire method, for example "iban" or "x-taler-bank"
+ wire_method: string;
+}
+
+export interface CreateMerchantTippingReserveConfirmation {
+ // Public key identifying the reserve
+ reserve_pub: string;
+
+ // Wire account of the exchange where to transfer the funds
+ payto_uri: string;
+}
+
+export class MerchantService implements MerchantServiceInterface {
+ static fromExistingConfig(gc: GlobalTestState, name: string) {
+ const cfgFilename = gc.testDir + `/merchant-${name}.conf`;
+ const config = Configuration.load(cfgFilename);
+ const mc: MerchantConfig = {
+ currency: config.getString("taler", "currency").required(),
+ database: config.getString("merchantdb-postgres", "config").required(),
+ httpPort: config.getNumber("merchant", "port").required(),
+ name,
+ };
+ return new MerchantService(gc, mc, cfgFilename);
+ }
+
+ proc: ProcessWrapper | undefined;
+
+ constructor(
+ private globalState: GlobalTestState,
+ private merchantConfig: MerchantConfig,
+ private configFilename: string,
+ ) {}
+
+ private currentTimetravel: Duration | undefined;
+
+ private isRunning(): boolean {
+ return !!this.proc;
+ }
+
+ setTimetravel(t: Duration | undefined): void {
+ if (this.isRunning()) {
+ throw Error("can't set time travel while the exchange is running");
+ }
+ this.currentTimetravel = t;
+ }
+
+ private get timetravelArg(): string | undefined {
+ if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
+ // Convert to microseconds
+ return `--timetravel=+${this.currentTimetravel.d_ms * 1000}`;
+ }
+ return undefined;
+ }
+
+ /**
+ * Return an empty array if no time travel is set,
+ * and an array with the time travel command line argument
+ * otherwise.
+ */
+ private get timetravelArgArr(): string[] {
+ const tta = this.timetravelArg;
+ if (tta) {
+ return [tta];
+ }
+ return [];
+ }
+
+ get port(): number {
+ return this.merchantConfig.httpPort;
+ }
+
+ get name(): string {
+ return this.merchantConfig.name;
+ }
+
+ async stop(): Promise<void> {
+ const httpd = this.proc;
+ if (httpd) {
+ httpd.proc.kill("SIGTERM");
+ await httpd.wait();
+ this.proc = undefined;
+ }
+ }
+
+ async start(): Promise<void> {
+ await exec(`taler-merchant-dbinit -c "${this.configFilename}"`);
+
+ this.proc = this.globalState.spawnService(
+ "taler-merchant-httpd",
+ ["-LDEBUG", "-c", this.configFilename, ...this.timetravelArgArr],
+ `merchant-${this.merchantConfig.name}`,
+ );
+ }
+
+ static async create(
+ gc: GlobalTestState,
+ mc: MerchantConfig,
+ ): Promise<MerchantService> {
+ const config = new Configuration();
+ config.setString("taler", "currency", mc.currency);
+
+ const cfgFilename = gc.testDir + `/merchant-${mc.name}.conf`;
+ setPaths(config, gc.testDir + "/talerhome");
+ config.setString("merchant", "serve", "tcp");
+ config.setString("merchant", "port", `${mc.httpPort}`);
+ config.setString(
+ "merchant",
+ "keyfile",
+ "${TALER_DATA_HOME}/merchant/merchant.priv",
+ );
+ config.setString("merchantdb-postgres", "config", mc.database);
+ 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" },
+ });
+ }
+
+ makeInstanceBaseUrl(instanceName?: string): string {
+ if (instanceName === undefined || instanceName === "default") {
+ return `http://localhost:${this.merchantConfig.httpPort}/`;
+ } else {
+ return `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/`;
+ }
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `http://localhost:${this.merchantConfig.httpPort}/config`;
+ await pingProc(this.proc, url, `merchant (${this.merchantConfig.name})`);
+ }
+}
+
+export interface MerchantInstanceConfig {
+ id: string;
+ name: string;
+ paytoUris: string[];
+ address?: unknown;
+ jurisdiction?: unknown;
+ defaultMaxWireFee?: string;
+ defaultMaxDepositFee?: string;
+ defaultWireFeeAmortization?: number;
+ defaultWireTransferDelay?: Duration;
+ defaultPayDelay?: Duration;
+}
+
+type TestStatus = "pass" | "fail" | "skip";
+
+export interface TestRunResult {
+ /**
+ * Name of the test.
+ */
+ name: string;
+
+ /**
+ * How long did the test run?
+ */
+ timeSec: number;
+
+ status: TestStatus;
+}
+
+export async function runTestWithState(
+ gc: GlobalTestState,
+ testMain: (t: GlobalTestState) => Promise<void>,
+ testName: string,
+): Promise<TestRunResult> {
+ const startMs = new Date().getTime();
+
+ const handleSignal = () => {
+ gc.shutdownSync();
+ console.warn("**** received fatal signal, shutting down test harness");
+ process.exit(1);
+ };
+
+ process.on("SIGINT", handleSignal);
+ process.on("SIGTERM", handleSignal);
+ process.on("unhandledRejection", handleSignal);
+ process.on("uncaughtException", handleSignal);
+
+ let status: TestStatus;
+ try {
+ console.log("running test in directory", gc.testDir);
+ await testMain(gc);
+ status = "pass";
+ } catch (e) {
+ console.error("FATAL: test failed with exception", e);
+ status = "fail";
+ } finally {
+ await gc.shutdown();
+ }
+ const afterMs = new Date().getTime();
+ return {
+ name: testName,
+ timeSec: (afterMs - startMs) / 1000,
+ status,
+ };
+}
+
+function shellWrap(s: string) {
+ return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'";
+}
+
+export class WalletCli {
+ private currentTimetravel: Duration | undefined;
+
+ setTimetravel(d: Duration | undefined) {
+ this.currentTimetravel = d;
+ }
+
+ private get timetravelArg(): string | undefined {
+ if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
+ // Convert to microseconds
+ return `--timetravel=${this.currentTimetravel.d_ms * 1000}`;
+ }
+ return undefined;
+ }
+
+ constructor(
+ private globalTestState: GlobalTestState,
+ private name: string = "default",
+ ) {}
+
+ get dbfile(): string {
+ return this.globalTestState.testDir + `/walletdb-${this.name}.json`;
+ }
+
+ deleteDatabase() {
+ fs.unlinkSync(this.dbfile);
+ }
+
+ private get timetravelArgArr(): string[] {
+ const tta = this.timetravelArg;
+ if (tta) {
+ return [tta];
+ }
+ return [];
+ }
+
+ async apiRequest(
+ request: string,
+ payload: unknown,
+ ): Promise<CoreApiResponse> {
+ const resp = await sh(
+ this.globalTestState,
+ `wallet-${this.name}`,
+ `taler-wallet-cli ${
+ this.timetravelArg ?? ""
+ } --no-throttle --wallet-db '${this.dbfile}' api '${request}' ${shellWrap(
+ JSON.stringify(payload),
+ )}`,
+ );
+ console.log(resp);
+ return JSON.parse(resp) as CoreApiResponse;
+ }
+
+ async runUntilDone(args: { maxRetries?: number } = {}): Promise<void> {
+ await runCommand(
+ this.globalTestState,
+ `wallet-${this.name}`,
+ "taler-wallet-cli",
+ [
+ "--no-throttle",
+ ...this.timetravelArgArr,
+ "--wallet-db",
+ this.dbfile,
+ "run-until-done",
+ ...(args.maxRetries ? ["--max-retries", `${args.maxRetries}`] : []),
+ ],
+ );
+ }
+
+ async runPending(): Promise<void> {
+ await runCommand(
+ this.globalTestState,
+ `wallet-${this.name}`,
+ "taler-wallet-cli",
+ [
+ "--no-throttle",
+ ...this.timetravelArgArr,
+ "--wallet-db",
+ this.dbfile,
+ "run-pending",
+ ],
+ );
+ }
+
+ async applyRefund(req: ApplyRefundRequest): Promise<ApplyRefundResponse> {
+ const resp = await this.apiRequest("applyRefund", req);
+ if (resp.type === "response") {
+ return codecForApplyRefundResponse().decode(resp.result);
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async preparePay(req: PreparePayRequest): Promise<PreparePayResult> {
+ const resp = await this.apiRequest("preparePay", req);
+ if (resp.type === "response") {
+ return codecForPreparePayResult().decode(resp.result);
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async abortFailedPayWithRefund(
+ req: AbortPayWithRefundRequest,
+ ): Promise<void> {
+ const resp = await this.apiRequest("abortFailedPayWithRefund", req);
+ if (resp.type === "response") {
+ return;
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async confirmPay(req: ConfirmPayRequest): Promise<ConfirmPayResult> {
+ const resp = await this.apiRequest("confirmPay", req);
+ if (resp.type === "response") {
+ return codecForConfirmPayResult().decode(resp.result);
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async prepareTip(req: PrepareTipRequest): Promise<PrepareTipResult> {
+ const resp = await this.apiRequest("prepareTip", req);
+ if (resp.type === "response") {
+ return codecForPrepareTipResult().decode(resp.result);
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async acceptTip(req: AcceptTipRequest): Promise<void> {
+ const resp = await this.apiRequest("acceptTip", req);
+ if (resp.type === "response") {
+ return;
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async dumpCoins(): Promise<CoinDumpJson> {
+ const resp = await this.apiRequest("dumpCoins", {});
+ if (resp.type === "response") {
+ return codecForAny().decode(resp.result);
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async addExchange(req: AddExchangeRequest): Promise<void> {
+ const resp = await this.apiRequest("addExchange", req);
+ if (resp.type === "response") {
+ return;
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async forceUpdateExchange(req: ForceExchangeUpdateRequest): Promise<void> {
+ const resp = await this.apiRequest("forceUpdateExchange", req);
+ if (resp.type === "response") {
+ return;
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async forceRefresh(req: ForceRefreshRequest): Promise<void> {
+ const resp = await this.apiRequest("forceRefresh", req);
+ if (resp.type === "response") {
+ return;
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async listExchanges(): Promise<ExchangesListRespose> {
+ const resp = await this.apiRequest("listExchanges", {});
+ if (resp.type === "response") {
+ return codecForExchangesListResponse().decode(resp.result);
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async getBalances(): Promise<BalancesResponse> {
+ const resp = await this.apiRequest("getBalances", {});
+ if (resp.type === "response") {
+ return codecForBalancesResponse().decode(resp.result);
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async getPendingOperations(): Promise<PendingOperationsResponse> {
+ const resp = await this.apiRequest("getPendingOperations", {});
+ if (resp.type === "response") {
+ // FIXME: validate properly!
+ return codecForAny().decode(resp.result);
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async getTransactions(): Promise<TransactionsResponse> {
+ const resp = await this.apiRequest("getTransactions", {});
+ if (resp.type === "response") {
+ return codecForTransactionsResponse().decode(resp.result);
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async runIntegrationTest(args: IntegrationTestArgs): Promise<void> {
+ const resp = await this.apiRequest("runIntegrationTest", args);
+ if (resp.type === "response") {
+ return;
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async testPay(args: TestPayArgs): Promise<void> {
+ const resp = await this.apiRequest("testPay", args);
+ if (resp.type === "response") {
+ return;
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async withdrawTestBalance(args: WithdrawTestBalanceRequest): Promise<void> {
+ const resp = await this.apiRequest("withdrawTestBalance", args);
+ if (resp.type === "response") {
+ return;
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
+ async getWithdrawalDetailsForUri(
+ req: GetWithdrawalDetailsForUriRequest,
+ ): Promise<WithdrawUriInfoResponse> {
+ const resp = await this.apiRequest("getWithdrawalDetailsForUri", req);
+ if (resp.type === "response") {
+ return codecForWithdrawUriInfoResponse().decode(resp.result);
+ }
+ throw new OperationFailedError(resp.error);
+ }
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/helpers.ts b/packages/taler-wallet-cli/src/integrationtests/helpers.ts
new file mode 100644
index 000000000..f4e676b61
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/helpers.ts
@@ -0,0 +1,370 @@
+/*
+ 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,
+ ExchangeBankAccount,
+ MerchantServiceInterface,
+ BankApi,
+ BankAccessApi,
+ MerchantPrivateApi,
+ ExchangeServiceInterface,
+} from "./harness";
+import {
+ AmountString,
+ Duration,
+ PreparePayResultType,
+ ConfirmPayResultType,
+ ContractTerms,
+} from "taler-wallet-core";
+import { FaultInjectedMerchantService } from "./faultInjection";
+import { defaultCoinConfig } from "./denomStructures";
+
+export interface SimpleTestEnvironment {
+ commonDb: DbInfo;
+ bank: BankService;
+ exchange: ExchangeService;
+ exchangeBankAccount: ExchangeBankAccount;
+ 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,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "MyExchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ 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,
+ exchangeBankAccount,
+ };
+}
+
+export interface FaultyMerchantTestEnvironment {
+ commonDb: DbInfo;
+ bank: BankService;
+ exchange: ExchangeService;
+ exchangeBankAccount: ExchangeBankAccount;
+ merchant: MerchantService;
+ faultyMerchant: FaultInjectedMerchantService;
+ wallet: WalletCli;
+}
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ */
+export async function createFaultInjectedMerchantTestkudosEnvironment(
+ t: GlobalTestState,
+): Promise<FaultyMerchantTestEnvironment> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const faultyMerchant = new FaultInjectedMerchantService(t, merchant, 9083);
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "MyExchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ 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,
+ exchangeBankAccount,
+ faultyMerchant,
+ };
+}
+
+/**
+ * Withdraw balance.
+ */
+export async function startWithdrawViaBank(
+ t: GlobalTestState,
+ p: {
+ wallet: WalletCli;
+ bank: BankService;
+ exchange: ExchangeServiceInterface;
+ amount: AmountString;
+ },
+): Promise<void> {
+ const { wallet, bank, exchange, amount } = p;
+
+ const user = await BankApi.createRandomBankUser(bank);
+ const wop = await BankAccessApi.createWithdrawalOperation(bank, 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 BankApi.confirmWithdrawalOperation(bank, user, wop);
+
+ // Withdraw
+
+ const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+ t.assertTrue(r2.type === "response");
+}
+
+/**
+ * Withdraw balance.
+ */
+export async function withdrawViaBank(
+ t: GlobalTestState,
+ p: {
+ wallet: WalletCli;
+ bank: BankService;
+ exchange: ExchangeServiceInterface;
+ amount: AmountString;
+ },
+): Promise<void> {
+ const { wallet } = p;
+
+ await startWithdrawViaBank(t, p);
+
+ await wallet.runUntilDone();
+
+ // Check balance
+
+ const balApiResp = await wallet.apiRequest("getBalances", {});
+ t.assertTrue(balApiResp.type === "response");
+}
+
+export async function applyTimeTravel(
+ timetravelDuration: Duration,
+ s: {
+ exchange?: ExchangeService;
+ merchant?: MerchantService;
+ wallet?: WalletCli;
+ },
+): Promise<void> {
+ if (s.exchange) {
+ await s.exchange.stop();
+ s.exchange.setTimetravel(timetravelDuration);
+ await s.exchange.start();
+ await s.exchange.pingUntilAvailable();
+ }
+
+ if (s.merchant) {
+ await s.merchant.stop();
+ s.merchant.setTimetravel(timetravelDuration);
+ await s.merchant.start();
+ await s.merchant.pingUntilAvailable();
+ }
+
+ if (s.wallet) {
+ s.wallet.setTimetravel(timetravelDuration);
+ }
+}
+
+/**
+ * Make a simple payment and check that it succeeded.
+ */
+export async function makeTestPayment(
+ t: GlobalTestState,
+ args: {
+ merchant: MerchantServiceInterface;
+ wallet: WalletCli;
+ order: Partial<ContractTerms>;
+ instance?: string;
+ },
+): Promise<void> {
+ // Set up order.
+
+ const { wallet, merchant } = args;
+ const instance = args.instance ?? "default";
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, instance, {
+ order: args.order,
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await wallet.preparePay({
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const r2 = await wallet.confirmPay({
+ proposalId: preparePayResult.proposalId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ instance,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/merchantApiTypes.ts b/packages/taler-wallet-cli/src/integrationtests/merchantApiTypes.ts
new file mode 100644
index 000000000..6782391a2
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/merchantApiTypes.ts
@@ -0,0 +1,304 @@
+/*
+ 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 {
+ ContractTerms,
+ Duration,
+ Codec,
+ buildCodecForObject,
+ codecForString,
+ codecOptional,
+ codecForConstString,
+ codecForBoolean,
+ codecForNumber,
+ codecForContractTerms,
+ codecForAny,
+ buildCodecForUnion,
+ AmountString,
+ Timestamp,
+ CoinPublicKeyString,
+} from "taler-wallet-core";
+import { codecForAmountString } from "taler-wallet-core/lib/util/amounts";
+
+export interface PostOrderRequest {
+ // The order must at least contain the minimal
+ // order detail, but can override all
+ order: Partial<ContractTerms>;
+
+ // if set, the backend will then set the refund deadline to the current
+ // time plus the specified delay.
+ refund_delay?: 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<PostOrderResponse> =>
+ buildCodecForObject<PostOrderResponse>()
+ .property("order_id", codecForString())
+ .property("token", codecOptional(codecForString()))
+ .build("PostOrderResponse");
+
+export const codecForCheckPaymentPaidResponse = (): Codec<
+ CheckPaymentPaidResponse
+> =>
+ buildCodecForObject<CheckPaymentPaidResponse>()
+ .property("order_status_url", codecForString())
+ .property("order_status", codecForConstString("paid"))
+ .property("refunded", codecForBoolean())
+ .property("wired", codecForBoolean())
+ .property("deposit_total", codecForAmountString())
+ .property("exchange_ec", codecForNumber())
+ .property("exchange_hc", codecForNumber())
+ .property("refund_amount", codecForAmountString())
+ .property("contract_terms", codecForContractTerms())
+ // FIXME: specify
+ .property("wire_details", codecForAny())
+ .property("wire_reports", codecForAny())
+ .property("refund_details", codecForAny())
+ .build("CheckPaymentPaidResponse");
+
+export const codecForCheckPaymentUnpaidResponse = (): Codec<
+ CheckPaymentUnpaidResponse
+> =>
+ buildCodecForObject<CheckPaymentUnpaidResponse>()
+ .property("order_status", codecForConstString("unpaid"))
+ .property("taler_pay_uri", codecForString())
+ .property("order_status_url", codecForString())
+ .property("already_paid_order_id", codecOptional(codecForString()))
+ .build("CheckPaymentPaidResponse");
+
+export const codecForCheckPaymentClaimedResponse = (): Codec<
+ CheckPaymentClaimedResponse
+> =>
+ buildCodecForObject<CheckPaymentClaimedResponse>()
+ .property("order_status", codecForConstString("claimed"))
+ .property("contract_terms", codecForContractTerms())
+ .build("CheckPaymentClaimedResponse");
+
+export const codecForMerchantOrderPrivateStatusResponse = (): Codec<
+ MerchantOrderPrivateStatusResponse
+> =>
+ buildCodecForUnion<MerchantOrderPrivateStatusResponse>()
+ .discriminateOn("order_status")
+ .alternative("paid", codecForCheckPaymentPaidResponse())
+ .alternative("unpaid", codecForCheckPaymentUnpaidResponse())
+ .alternative("claimed", codecForCheckPaymentClaimedResponse())
+ .build("MerchantOrderPrivateStatusResponse");
+
+export type MerchantOrderPrivateStatusResponse =
+ | CheckPaymentPaidResponse
+ | CheckPaymentUnpaidResponse
+ | CheckPaymentClaimedResponse;
+
+export interface CheckPaymentClaimedResponse {
+ // Wallet claimed the order, but didn't pay yet.
+ order_status: "claimed";
+
+ contract_terms: ContractTerms;
+}
+
+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: 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: AmountString;
+
+ // Contract terms
+ contract_terms: 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[];
+
+ order_status_url: string;
+}
+
+export interface CheckPaymentUnpaidResponse {
+ order_status: "unpaid";
+
+ // URI that the wallet must process to complete the payment.
+ taler_pay_uri: string;
+
+ order_status_url: 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: Timestamp;
+
+ // Total amount that was refunded (minus a refund fee).
+ amount: AmountString;
+}
+
+export interface TransactionWireTransfer {
+ // Responsible exchange
+ exchange_url: string;
+
+ // 32-byte wire transfer identifier
+ wtid: string;
+
+ // execution time of the wire transfer
+ execution_time: Timestamp;
+
+ // Total amount that has been wire transfered
+ // to the merchant
+ amount: 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: CoinPublicKeyString;
+}
+
+export interface TippingReserveStatus {
+ // Array of all known reserves (possibly empty!)
+ reserves: ReserveStatusEntry[];
+}
+
+export interface ReserveStatusEntry {
+ // Public key of the reserve
+ reserve_pub: string;
+
+ // Timestamp when it was established
+ creation_time: Timestamp;
+
+ // Timestamp when it expires
+ expiration_time: Timestamp;
+
+ // Initial amount as per reserve creation call
+ merchant_initial_amount: AmountString;
+
+ // Initial amount as per exchange, 0 if exchange did
+ // not confirm reserve creation yet.
+ exchange_initial_amount: AmountString;
+
+ // Amount picked up so far.
+ pickup_amount: AmountString;
+
+ // Amount approved for tips that exceeds the pickup_amount.
+ committed_amount: AmountString;
+
+ // Is this reserve active (false if it was deleted but not purged)
+ active: boolean;
+}
+
+export interface TipCreateConfirmation {
+ // Unique tip identifier for the tip that was created.
+ tip_id: string;
+
+ // taler://tip URI for the tip
+ taler_tip_uri: string;
+
+ // URL that will directly trigger processing
+ // the tip when the browser is redirected to it
+ tip_status_url: string;
+
+ // when does the tip expire
+ tip_expiration: Timestamp;
+}
+
+export interface TipCreateRequest {
+ // Amount that the customer should be tipped
+ amount: AmountString;
+
+ // Justification for giving the tip
+ justification: string;
+
+ // URL that the user should be directed to after tipping,
+ // will be included in the tip_token.
+ next_url: string;
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/scenario-prompt-payment.ts b/packages/taler-wallet-cli/src/integrationtests/scenario-prompt-payment.ts
new file mode 100644
index 000000000..e3c2af8e6
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/scenario-prompt-payment.ts
@@ -0,0 +1,60 @@
+/*
+ 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 { GlobalTestState, MerchantPrivateApi } from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runPromptPaymentScenario(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 MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ console.log(orderStatus);
+
+ // Wait "forever"
+ await new Promise(() => {});
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-bank-api.ts b/packages/taler-wallet-cli/src/integrationtests/test-bank-api.ts
new file mode 100644
index 000000000..b5cf6d5ba
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-bank-api.ts
@@ -0,0 +1,136 @@
+/*
+ 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 {
+ GlobalTestState,
+ WalletCli,
+ ExchangeService,
+ setupDb,
+ BankService,
+ MerchantService,
+ BankApi,
+ BankAccessApi,
+ CreditDebitIndicator,
+} from "./harness";
+import { createEddsaKeyPair, encodeCrock } from "taler-wallet-core";
+import { defaultCoinConfig } from "./denomStructures";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runBankApiTest(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,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "MyExchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ 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);
+
+ const bankUser = await BankApi.registerAccount(bank, "user1", "pw1");
+
+ // Make sure that registering twice results in a 409 Conflict
+ {
+ const e = await t.assertThrowsAsync(async () => {
+ await BankApi.registerAccount(bank, "user1", "pw1");
+ });
+ t.assertAxiosError(e);
+ t.assertTrue(e.response?.status === 409);
+ }
+
+ let balResp = await BankAccessApi.getAccountBalance(bank, bankUser);
+
+ console.log(balResp);
+
+ // Check that we got the sign-up bonus.
+ t.assertAmountEquals(balResp.balance.amount, "TESTKUDOS:100");
+ t.assertTrue(
+ balResp.balance.credit_debit_indicator === CreditDebitIndicator.Credit,
+ );
+
+ const res = createEddsaKeyPair();
+
+ await BankApi.adminAddIncoming(bank, {
+ amount: "TESTKUDOS:115",
+ debitAccountPayto: bankUser.accountPaytoUri,
+ exchangeBankAccount: exchangeBankAccount,
+ reservePub: encodeCrock(res.eddsaPub),
+ });
+
+ balResp = await BankAccessApi.getAccountBalance(bank, bankUser);
+ t.assertAmountEquals(balResp.balance.amount, "TESTKUDOS:15");
+ t.assertTrue(
+ balResp.balance.credit_debit_indicator === CreditDebitIndicator.Debit,
+ );
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-claim-loop.ts b/packages/taler-wallet-cli/src/integrationtests/test-claim-loop.ts
new file mode 100644
index 000000000..5a0540e90
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-claim-loop.ts
@@ -0,0 +1,81 @@
+/*
+ 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 {
+ GlobalTestState,
+ MerchantPrivateApi,
+} from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+import { URL } from "url";
+
+/**
+ * Run test for the merchant's order lifecycle.
+ *
+ * FIXME: Is this test still necessary? We initially wrote if to confirm/document
+ * assumptions about how the merchant should work.
+ */
+export async function runClaimLoopTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ // Set up order.
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ // Query private order status before claiming it.
+ let orderStatusBefore = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderResp.order_id,
+ },
+ );
+ t.assertTrue(orderStatusBefore.order_status === "unpaid");
+ let statusUrlBefore = new URL(orderStatusBefore.order_status_url);
+
+ // Make wallet claim the unpaid order.
+ t.assertTrue(orderStatusBefore.order_status === "unpaid");
+ const talerPayUri = orderStatusBefore.taler_pay_uri;
+ const y = await wallet.preparePay({
+ talerPayUri,
+ });
+
+ // Query private order status after claiming it.
+ let orderStatusAfter = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderResp.order_id,
+ },
+ );
+ t.assertTrue(orderStatusAfter.order_status === "claimed");
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts b/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts
new file mode 100644
index 000000000..0fbef5687
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts
@@ -0,0 +1,249 @@
+/*
+ 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 {
+ GlobalTestState,
+ WalletCli,
+ setupDb,
+ BankService,
+ ExchangeService,
+ MerchantService,
+ BankApi,
+ BankAccessApi,
+} from "./harness";
+import {
+ PreparePayResultType,
+ ExchangesListRespose,
+ URL,
+ TalerErrorCode,
+} from "taler-wallet-core";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectionResponseContext,
+} from "./faultInjection";
+import { defaultCoinConfig } from "./denomStructures";
+
+/**
+ * Test if the wallet handles outdated exchange versions correct.y
+ */
+export async function runExchangeManagementTest(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,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "MyExchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
+
+ bank.setSuggestedExchange(
+ faultyExchange,
+ exchangeBankAccount.accountPaytoUri,
+ );
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ 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!");
+
+ /*
+ * =========================================================================
+ * Check that the exchange can be added to the wallet
+ * (without any faults active).
+ * =========================================================================
+ */
+
+ const wallet = new WalletCli(t);
+
+ let exchangesList: ExchangesListRespose;
+
+ exchangesList = await wallet.listExchanges();
+ t.assertTrue(exchangesList.exchanges.length === 0);
+
+ // Try before fault is injected
+ await wallet.addExchange({
+ exchangeBaseUrl: faultyExchange.baseUrl,
+ });
+
+ exchangesList = await wallet.listExchanges();
+ t.assertTrue(exchangesList.exchanges.length === 1);
+
+ await wallet.addExchange({
+ exchangeBaseUrl: faultyExchange.baseUrl,
+ });
+
+ console.log("listing exchanges");
+
+ exchangesList = await wallet.listExchanges();
+ t.assertTrue(exchangesList.exchanges.length === 1);
+
+ console.log("got list", exchangesList);
+
+ /*
+ * =========================================================================
+ * Check what happens if the exchange returns something totally
+ * bogus for /keys.
+ * =========================================================================
+ */
+
+ wallet.deleteDatabase();
+
+ exchangesList = await wallet.listExchanges();
+ t.assertTrue(exchangesList.exchanges.length === 0);
+
+ faultyExchange.faultProxy.addFault({
+ modifyResponse(ctx: FaultInjectionResponseContext) {
+ const url = new URL(ctx.request.requestUrl);
+ if (url.pathname === "/keys") {
+ const body = {
+ version: "whaaat",
+ };
+ ctx.responseBody = Buffer.from(JSON.stringify(body), "utf-8");
+ }
+ },
+ });
+
+ const err1 = await t.assertThrowsOperationErrorAsync(async () => {
+ await wallet.addExchange({
+ exchangeBaseUrl: faultyExchange.baseUrl,
+ });
+ });
+
+ // Response is malformed, since it didn't even contain a version code
+ // in a format the wallet can understand.
+ t.assertTrue(
+ err1.operationError.code ===
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ );
+
+ exchangesList = await wallet.listExchanges();
+ t.assertTrue(exchangesList.exchanges.length === 0);
+
+ /*
+ * =========================================================================
+ * Check what happens if the exchange returns an old, unsupported
+ * version for /keys
+ * =========================================================================
+ */
+
+ wallet.deleteDatabase();
+ faultyExchange.faultProxy.clearAllFaults();
+
+ faultyExchange.faultProxy.addFault({
+ modifyResponse(ctx: FaultInjectionResponseContext) {
+ const url = new URL(ctx.request.requestUrl);
+ if (url.pathname === "/keys") {
+ const keys = ctx.responseBody?.toString("utf-8");
+ t.assertTrue(keys != null);
+ const keysJson = JSON.parse(keys);
+ keysJson["version"] = "2:0:0";
+ ctx.responseBody = Buffer.from(JSON.stringify(keysJson), "utf-8");
+ }
+ },
+ });
+
+ const err2 = await t.assertThrowsOperationErrorAsync(async () => {
+ await wallet.addExchange({
+ exchangeBaseUrl: faultyExchange.baseUrl,
+ });
+ });
+
+ t.assertTrue(
+ err2.operationError.code ===
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ );
+
+ exchangesList = await wallet.listExchanges();
+ t.assertTrue(exchangesList.exchanges.length === 0);
+
+ /*
+ * =========================================================================
+ * Check that the exchange version is also checked when
+ * the exchange is implicitly added via the suggested
+ * exchange of a bank-integrated withdrawal.
+ * =========================================================================
+ */
+
+ // Fault from above is still active!
+
+ // Create withdrawal operation
+
+ const user = await BankApi.createRandomBankUser(bank);
+ const wop = await BankAccessApi.createWithdrawalOperation(
+ bank,
+ user,
+ "TESTKUDOS:10",
+ );
+
+ // Hand it to the wallet
+
+ const wd = await wallet.getWithdrawalDetailsForUri({
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+
+ // Make sure the faulty exchange isn't used for the suggestion.
+ t.assertTrue(wd.possibleExchanges.length === 0);
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-fee-regression.ts b/packages/taler-wallet-cli/src/integrationtests/test-fee-regression.ts
new file mode 100644
index 000000000..c56fe7abf
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-fee-regression.ts
@@ -0,0 +1,204 @@
+/*
+ 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 {
+ GlobalTestState,
+ BankService,
+ ExchangeService,
+ MerchantService,
+ setupDb,
+ WalletCli,
+} from "./harness";
+import {
+ withdrawViaBank,
+ makeTestPayment,
+ SimpleTestEnvironment,
+} from "./helpers";
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ */
+export async function createMyTestkudosEnvironment(
+ t: GlobalTestState,
+): Promise<SimpleTestEnvironment> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "MyExchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinCommon = {
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ rsaKeySize: 1024,
+ feeDeposit: "TESTKUDOS:0.0025",
+ feeWithdraw: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ };
+
+ exchange.addCoinConfigList([
+ {
+ ...coinCommon,
+ name: "c1",
+ value: "TESTKUDOS:1.28",
+ },
+ {
+ ...coinCommon,
+ name: "c2",
+ value: "TESTKUDOS:0.64",
+ },
+ {
+ ...coinCommon,
+ name: "c3",
+ value: "TESTKUDOS:0.32",
+ },
+ {
+ ...coinCommon,
+ name: "c4",
+ value: "TESTKUDOS:0.16",
+ },
+ {
+ ...coinCommon,
+ name: "c5",
+ value: "TESTKUDOS:0.08",
+ },
+ {
+ ...coinCommon,
+ name: "c5",
+ value: "TESTKUDOS:0.04",
+ },
+ {
+ ...coinCommon,
+ name: "c6",
+ value: "TESTKUDOS:0.02",
+ },
+ {
+ ...coinCommon,
+ name: "c7",
+ value: "TESTKUDOS:0.01",
+ },
+ ]);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ 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,
+ exchangeBankAccount,
+ };
+}
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runFeeRegressionTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createMyTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:1.92",
+ });
+
+ const coins = await wallet.dumpCoins();
+
+ // Make sure we really withdraw one 0.64 and one 1.28 coin.
+ t.assertTrue(coins.coins.length === 2);
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:1.30",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+
+ await wallet.runUntilDone();
+
+ const txs = await wallet.getTransactions();
+ t.assertAmountEquals(txs.transactions[1].amountEffective, "TESTKUDOS:1.30");
+ console.log(txs);
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-longpolling.ts b/packages/taler-wallet-cli/src/integrationtests/test-merchant-longpolling.ts
new file mode 100644
index 000000000..7ceccbf62
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-merchant-longpolling.ts
@@ -0,0 +1,132 @@
+/*
+ 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 { GlobalTestState, MerchantPrivateApi } from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+import {
+ PreparePayResultType,
+ codecForMerchantOrderStatusUnpaid,
+ ConfirmPayResultType,
+ URL,
+} from "taler-wallet-core";
+import axios from "axios";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runMerchantLongpollingTest(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" });
+
+ /**
+ * =========================================================================
+ * Create an order and let the wallet pay under a session ID
+ *
+ * We check along the way that the JSON response to /orders/{order_id}
+ * returns the right thing.
+ * =========================================================================
+ */
+
+ let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ },
+ });
+
+ const firstOrderId = orderResp.order_id;
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-one",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ let publicOrderStatusUrl = new URL(orderStatus.order_status_url);
+
+ // Wait for half a second seconds!
+ publicOrderStatusUrl.searchParams.set("timeout_ms", "500");
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ console.log(pubUnpaidStatus);
+
+ /**
+ * =========================================================================
+ * Now actually pay, but WHILE a long poll is active!
+ * =========================================================================
+ */
+
+ publicOrderStatusUrl.searchParams.set("timeout_ms", "5000");
+
+ let publicOrderStatusPromise = axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+
+ let preparePayResp = await wallet.preparePay({
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ });
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ const proposalId = preparePayResp.proposalId;
+
+ publicOrderStatusResp = await publicOrderStatusPromise;
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ const confirmPayRes = await wallet.confirmPay({
+ proposalId: proposalId,
+ });
+
+ t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-refund-api.ts b/packages/taler-wallet-cli/src/integrationtests/test-merchant-refund-api.ts
new file mode 100644
index 000000000..27cf34b58
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-merchant-refund-api.ts
@@ -0,0 +1,284 @@
+/*
+ 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 {
+ GlobalTestState,
+ MerchantPrivateApi,
+ BankServiceInterface,
+ MerchantServiceInterface,
+ WalletCli,
+ ExchangeServiceInterface,
+} from "./harness";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ SimpleTestEnvironment,
+} from "./helpers";
+import { durationFromSpec, PreparePayResultType, URL } from "taler-wallet-core";
+import axios from "axios";
+
+async function testRefundApiWithFulfillmentUrl(
+ t: GlobalTestState,
+ env: {
+ merchant: MerchantServiceInterface;
+ bank: BankServiceInterface;
+ wallet: WalletCli;
+ exchange: ExchangeServiceInterface;
+ },
+): Promise<void> {
+ const { wallet, bank, exchange, merchant } = env;
+
+ // Set up order.
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/fulfillment",
+ },
+ refund_delay: durationFromSpec({ minutes: 5 }),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUri = orderStatus.taler_pay_uri;
+ const orderId = orderResp.order_id;
+
+ // Make wallet pay for the order
+
+ let preparePayResult = await wallet.preparePay({
+ talerPayUri,
+ });
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const r2 = await wallet.apiRequest("confirmPay", {
+ proposalId: preparePayResult.proposalId,
+ });
+ t.assertTrue(r2.type === "response");
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ preparePayResult = await wallet.preparePay({
+ talerPayUri,
+ });
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.AlreadyConfirmed,
+ );
+
+ await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ t.assertAmountEquals(orderStatus.refund_amount, "TESTKUDOS:5");
+
+ // Now test what the merchant gives as a response for various requests to the
+ // public order status URL!
+
+ let publicOrderStatusUrl = new URL(
+ `orders/${orderId}`,
+ merchant.makeInstanceBaseUrl(),
+ );
+ publicOrderStatusUrl.searchParams.set(
+ "h_contract",
+ preparePayResult.contractTermsHash,
+ );
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+ console.log(publicOrderStatusResp.data);
+ t.assertTrue(publicOrderStatusResp.status === 200);
+ t.assertAmountEquals(publicOrderStatusResp.data.refund_amount, "TESTKUDOS:5");
+
+ publicOrderStatusUrl = new URL(
+ `orders/${orderId}`,
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+ console.log(publicOrderStatusResp.data);
+ // We didn't give any authentication, so we should get a fulfillment URL back
+ t.assertTrue(publicOrderStatusResp.status === 202);
+ const fu = publicOrderStatusResp.data.fulfillment_url;
+ t.assertTrue(typeof fu === "string" && fu.startsWith("https://example.com"));
+}
+
+async function testRefundApiWithFulfillmentMessage(
+ t: GlobalTestState,
+ env: {
+ merchant: MerchantServiceInterface;
+ bank: BankServiceInterface;
+ wallet: WalletCli;
+ exchange: ExchangeServiceInterface;
+ },
+): Promise<void> {
+ const { wallet, bank, exchange, merchant } = env;
+
+ // Set up order.
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_message: "Thank you for buying foobar",
+ },
+ refund_delay: durationFromSpec({ minutes: 5 }),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUri = orderStatus.taler_pay_uri;
+ const orderId = orderResp.order_id;
+
+ // Make wallet pay for the order
+
+ let preparePayResult = await wallet.preparePay({
+ talerPayUri,
+ });
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const r2 = await wallet.apiRequest("confirmPay", {
+ proposalId: preparePayResult.proposalId,
+ });
+ t.assertTrue(r2.type === "response");
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ preparePayResult = await wallet.preparePay({
+ talerPayUri,
+ });
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.AlreadyConfirmed,
+ );
+
+ await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ t.assertAmountEquals(orderStatus.refund_amount, "TESTKUDOS:5");
+
+ // Now test what the merchant gives as a response for various requests to the
+ // public order status URL!
+
+ let publicOrderStatusUrl = new URL(
+ `orders/${orderId}`,
+ merchant.makeInstanceBaseUrl(),
+ );
+ publicOrderStatusUrl.searchParams.set(
+ "h_contract",
+ preparePayResult.contractTermsHash,
+ );
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+ console.log(publicOrderStatusResp.data);
+ t.assertTrue(publicOrderStatusResp.status === 200);
+ t.assertAmountEquals(publicOrderStatusResp.data.refund_amount, "TESTKUDOS:5");
+
+ publicOrderStatusUrl = new URL(
+ `orders/${orderId}`,
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
+ validateStatus: () => true,
+ });
+ console.log(publicOrderStatusResp.data);
+ // We didn't give any authentication, so we should get a fulfillment URL back
+ t.assertTrue(publicOrderStatusResp.status === 403);
+}
+
+/**
+ * Test case for the refund API of the merchant backend.
+ */
+export async function runMerchantRefundApiTest(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" });
+
+ await testRefundApiWithFulfillmentUrl(t, {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ });
+
+ await testRefundApiWithFulfillmentMessage(t, {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ });
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-pay-abort.ts b/packages/taler-wallet-cli/src/integrationtests/test-pay-abort.ts
new file mode 100644
index 000000000..4fd6edc92
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-pay-abort.ts
@@ -0,0 +1,199 @@
+/*
+ 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 test to check aborting partial payment
+ * via refunds.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ GlobalTestState,
+ MerchantService,
+ ExchangeService,
+ setupDb,
+ BankService,
+ WalletCli,
+ MerchantPrivateApi,
+} from "./harness";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectionRequestContext,
+ FaultInjectionResponseContext,
+} from "./faultInjection";
+import { PreparePayResultType, URL, TalerErrorCode } from "taler-wallet-core";
+import { defaultCoinConfig } from "./denomStructures";
+import { withdrawViaBank, makeTestPayment } from "./helpers";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runPayAbortTest(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,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "MyExchange",
+ "x",
+ );
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ await exchange.addBankAccount("1", exchangeBankAccount);
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
+
+ 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
+
+ await withdrawViaBank(t, {
+ wallet,
+ exchange: faultyExchange,
+ amount: "TESTKUDOS:20",
+ bank,
+ });
+
+ // faultyExchange.faultProxy.addFault({
+ // modifyRequest(ctx: FaultInjectionRequestContext) {
+ // console.log("proxy request to", ctx.requestUrl);
+ // }
+ // });
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:15",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await wallet.preparePay({
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ // We let only the first deposit through!
+ let firstDepositUrl: string | undefined;
+
+ faultyExchange.faultProxy.addFault({
+ modifyRequest(ctx: FaultInjectionRequestContext) {
+ const url = new URL(ctx.requestUrl);
+ if (url.pathname.endsWith("/deposit")) {
+ if (!firstDepositUrl) {
+ firstDepositUrl = url.href;
+ return;
+ }
+ if (url.href != firstDepositUrl) {
+ url.pathname = "/doesntexist";
+ ctx.requestUrl = url.href;
+ }
+ }
+ },
+ modifyResponse(ctx: FaultInjectionResponseContext) {
+ const url = new URL(ctx.request.requestUrl);
+ if (url.pathname.endsWith("/deposit") && url.href != firstDepositUrl) {
+ ctx.responseBody = Buffer.from("{}");
+ ctx.statusCode = 500;
+ }
+ },
+ });
+
+ await t.assertThrowsOperationErrorAsync(async () => {
+ await wallet.confirmPay({
+ proposalId: preparePayResult.proposalId,
+ });
+ });
+
+ let txr = await wallet.getTransactions();
+ console.log(JSON.stringify(txr, undefined, 2));
+
+ t.assertDeepEqual(txr.transactions[1].type, "payment");
+ t.assertDeepEqual(txr.transactions[1].pending, true);
+ t.assertDeepEqual(
+ txr.transactions[1].error?.code,
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ );
+
+ await wallet.abortFailedPayWithRefund({
+ proposalId: preparePayResult.proposalId,
+ });
+
+ await wallet.runUntilDone();
+
+ txr = await wallet.getTransactions();
+ console.log(JSON.stringify(txr, undefined, 2));
+
+ const txTypes = txr.transactions.map((x) => x.type);
+
+ t.assertDeepEqual(txTypes, ["withdrawal", "payment", "refund"]);
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-pay-paid.ts b/packages/taler-wallet-cli/src/integrationtests/test-pay-paid.ts
new file mode 100644
index 000000000..4d2706608
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-pay-paid.ts
@@ -0,0 +1,206 @@
+/*
+ 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 { GlobalTestState, MerchantPrivateApi } from "./harness";
+import {
+ withdrawViaBank,
+ createFaultInjectedMerchantTestkudosEnvironment,
+} from "./helpers";
+import {
+ PreparePayResultType,
+ codecForMerchantOrderStatusUnpaid,
+ ConfirmPayResultType,
+ URL,
+} from "taler-wallet-core";
+import axios from "axios";
+import { FaultInjectionRequestContext } from "./faultInjection";
+
+/**
+ * Run test for the wallets repurchase detection mechanism
+ * based on the fulfillment URL.
+ *
+ * FIXME: This test is now almost the same as test-paywall-flow,
+ * since we can't initiate payment via a "claimed" private order status
+ * response.
+ */
+export async function runPayPaidTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ faultyMerchant,
+ } = await createFaultInjectedMerchantTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ /**
+ * =========================================================================
+ * Create an order and let the wallet pay under a session ID
+ *
+ * We check along the way that the JSON response to /orders/{order_id}
+ * returns the right thing.
+ * =========================================================================
+ */
+
+ const merchant = faultyMerchant;
+
+ let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-one",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ let publicOrderStatusUrl = orderStatus.order_status_url;
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await wallet.preparePay({
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ });
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ const proposalId = preparePayResp.proposalId;
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ const confirmPayRes = await wallet.confirmPay({
+ proposalId: proposalId,
+ });
+
+ t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ console.log(publicOrderStatusResp.data);
+
+ if (publicOrderStatusResp.status != 202) {
+ console.log(publicOrderStatusResp.data);
+ throw Error(
+ `expected status 202 (after paying), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ /**
+ * =========================================================================
+ * Now change up the session ID and do payment re-play!
+ * =========================================================================
+ */
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-two",
+ });
+
+ console.log(
+ "order status under mysession-two:",
+ JSON.stringify(orderStatus, undefined, 2),
+ );
+
+ // Should be claimed (not paid!) because of a new session ID
+ t.assertTrue(orderStatus.order_status === "claimed");
+
+ let numPayRequested = 0;
+ let numPaidRequested = 0;
+
+ faultyMerchant.faultProxy.addFault({
+ modifyRequest(ctx: FaultInjectionRequestContext) {
+ const url = new URL(ctx.requestUrl);
+ if (url.pathname.endsWith("/pay")) {
+ numPayRequested++;
+ } else if (url.pathname.endsWith("/paid")) {
+ numPaidRequested++;
+ }
+ },
+ });
+
+ let orderRespTwo = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ },
+ });
+
+ let orderStatusTwo = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderRespTwo.order_id,
+ sessionId: "mysession-two",
+ },
+ );
+
+ t.assertTrue(orderStatusTwo.order_status === "unpaid");
+
+ // Pay with new taler://pay URI, which should
+ // have the new session ID!
+ // Wallet should now automatically re-play payment.
+ preparePayResp = await wallet.preparePay({
+ talerPayUri: orderStatusTwo.taler_pay_uri,
+ });
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.AlreadyConfirmed);
+ t.assertTrue(preparePayResp.paid);
+
+ // Make sure the wallet is actually doing the replay properly.
+ t.assertTrue(numPaidRequested == 1);
+ t.assertTrue(numPayRequested == 0);
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-claim.ts b/packages/taler-wallet-cli/src/integrationtests/test-payment-claim.ts
new file mode 100644
index 000000000..ee8a8e49c
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-payment-claim.ts
@@ -0,0 +1,104 @@
+/*
+ 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 { GlobalTestState, MerchantPrivateApi, WalletCli } from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+import { PreparePayResultType, TalerErrorCode } from "taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runPaymentClaimTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ const walletTwo = new WalletCli(t, "two");
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ // Set up order.
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUri = orderStatus.taler_pay_uri;
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await wallet.preparePay({
+ talerPayUri,
+ });
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ t.assertThrowsOperationErrorAsync(async () => {
+ await walletTwo.preparePay({
+ talerPayUri,
+ });
+ });
+
+ const r2 = await wallet.apiRequest("confirmPay", {
+ // FIXME: should be validated, don't cast!
+ proposalId: preparePayResult.proposalId,
+ });
+ t.assertTrue(r2.type === "response");
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ walletTwo.deleteDatabase();
+
+ const err = await t.assertThrowsOperationErrorAsync(async () => {
+ await walletTwo.preparePay({
+ talerPayUri,
+ });
+ });
+
+ t.assertTrue(
+ err.operationError.code === TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
+ );
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-fault.ts b/packages/taler-wallet-cli/src/integrationtests/test-payment-fault.ts
new file mode 100644
index 000000000..55609f3fc
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-payment-fault.ts
@@ -0,0 +1,209 @@
+/*
+ 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 {
+ GlobalTestState,
+ MerchantService,
+ ExchangeService,
+ setupDb,
+ BankService,
+ WalletCli,
+ MerchantPrivateApi,
+ BankApi,
+ BankAccessApi,
+} from "./harness";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectionRequestContext,
+ FaultInjectionResponseContext,
+} from "./faultInjection";
+import { CoreApiResponse } from "taler-wallet-core";
+import { defaultCoinConfig } from "./denomStructures";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runPaymentFaultTest(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,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "MyExchange",
+ "x",
+ );
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ await exchange.addBankAccount("1", exchangeBankAccount);
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ 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 BankApi.createRandomBankUser(bank);
+ const wop = await BankAccessApi.createWithdrawalOperation(
+ bank,
+ 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 BankApi.confirmWithdrawalOperation(bank, 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 MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: 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 3 responses from the exchange.
+ let faultCount = 0;
+ faultyExchange.faultProxy.addFault({
+ modifyResponse(ctx: FaultInjectionResponseContext) {
+ if (faultCount < 3) {
+ 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 MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts b/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts
new file mode 100644
index 000000000..4323a7c9d
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts
@@ -0,0 +1,103 @@
+/*
+ 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 { GlobalTestState, MerchantPrivateApi } from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+import { PreparePayResultType } from "taler-wallet-core";
+
+/**
+ * Test the wallet-core payment API, especially that repeated operations
+ * return the expected result.
+ */
+export async function runPaymentIdempotencyTest(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 MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUri = orderStatus.taler_pay_uri;
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await wallet.preparePay({
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ const preparePayResultRep = await wallet.preparePay({
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+ t.assertTrue(
+ preparePayResultRep.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const proposalId = preparePayResult.proposalId;
+
+ const r2 = await wallet.apiRequest("confirmPay", {
+ // FIXME: should be validated, don't cast!
+ proposalId: proposalId,
+ });
+ t.assertTrue(r2.type === "response");
+
+ // Check if payment was successful.
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ const preparePayResultAfter = await wallet.preparePay({
+ talerPayUri,
+ });
+
+ t.assertTrue(
+ preparePayResultAfter.status === PreparePayResultType.AlreadyConfirmed,
+ );
+ t.assertTrue(preparePayResultAfter.paid === true);
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-multiple.ts b/packages/taler-wallet-cli/src/integrationtests/test-payment-multiple.ts
new file mode 100644
index 000000000..6d05df33b
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-payment-multiple.ts
@@ -0,0 +1,160 @@
+/*
+ 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 {
+ GlobalTestState,
+ setupDb,
+ BankService,
+ ExchangeService,
+ MerchantService,
+ WalletCli,
+ MerchantPrivateApi,
+} from "./harness";
+import { withdrawViaBank } from "./helpers";
+import { coin_ct10, coin_u1 } from "./denomStructures";
+
+async function setupTest(
+ t: GlobalTestState,
+): Promise<{
+ merchant: MerchantService;
+ exchange: ExchangeService;
+ bank: BankService;
+}> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "MyExchange",
+ "x",
+ );
+
+ exchange.addOfferedCoins([coin_ct10, coin_u1]);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ await exchange.addBankAccount("1", exchangeBankAccount);
+
+ 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!");
+
+ return {
+ merchant,
+ bank,
+ exchange,
+ };
+}
+
+/**
+ * Run test.
+ *
+ * This test uses a very sub-optimal denomination structure.
+ */
+export async function runPaymentMultipleTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { merchant, bank, exchange } = await setupTest(t);
+
+ const wallet = new WalletCli(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:100" });
+
+ // Set up order.
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:80",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: 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 MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-transient.ts b/packages/taler-wallet-cli/src/integrationtests/test-payment-transient.ts
new file mode 100644
index 000000000..73973bdc0
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-payment-transient.ts
@@ -0,0 +1,172 @@
+/*
+ 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 { GlobalTestState, MerchantPrivateApi } from "./harness";
+import {
+ withdrawViaBank,
+ createFaultInjectedMerchantTestkudosEnvironment,
+} from "./helpers";
+import {
+ PreparePayResultType,
+ codecForMerchantOrderStatusUnpaid,
+ ConfirmPayResultType,
+ URL,
+ codecForExchangeKeysJson,
+ TalerErrorDetails,
+ TalerErrorCode,
+} from "taler-wallet-core";
+import axios from "axios";
+import {
+ FaultInjectionRequestContext,
+ FaultInjectionResponseContext,
+} from "./faultInjection";
+
+/**
+ * Run test for a payment where the merchant has a transient
+ * failure in /pay
+ */
+export async function runPaymentTransientTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ faultyMerchant,
+ } = await createFaultInjectedMerchantTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+ const merchant = faultyMerchant;
+
+ let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ },
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-one",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ let publicOrderStatusUrl = orderStatus.order_status_url;
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await wallet.preparePay({
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ });
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ const proposalId = preparePayResp.proposalId;
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ let faultInjected = false;
+
+ faultyMerchant.faultProxy.addFault({
+ modifyResponse(ctx: FaultInjectionResponseContext) {
+ console.log("in modifyResponse");
+ const url = new URL(ctx.request.requestUrl);
+ console.log("pathname is", url.pathname);
+ if (!url.pathname.endsWith("/pay")) {
+ return;
+ }
+ if (faultInjected) {
+ console.log("not injecting pay fault");
+ return;
+ }
+ faultInjected = true;
+ console.log("injecting pay fault");
+ const err: TalerErrorDetails = {
+ code: TalerErrorCode.GENERIC_DB_COMMIT_FAILED,
+ details: {},
+ hint: "huh",
+ message: "something went wrong",
+ };
+ ctx.responseBody = Buffer.from(JSON.stringify(err));
+ ctx.statusCode = 500;
+ },
+ });
+
+ const confirmPayResp = await wallet.confirmPay({
+ proposalId,
+ });
+
+ console.log(confirmPayResp);
+
+ t.assertTrue(confirmPayResp.type === ConfirmPayResultType.Pending);
+ t.assertTrue(faultInjected);
+
+ const confirmPayRespTwo = await wallet.confirmPay({
+ proposalId,
+ });
+
+ t.assertTrue(confirmPayRespTwo.type === ConfirmPayResultType.Done);
+
+ // Now ask the merchant if paid
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ console.log(publicOrderStatusResp.data);
+
+ if (publicOrderStatusResp.status != 202) {
+ console.log(publicOrderStatusResp.data);
+ throw Error(
+ `expected status 202 (after paying), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+} \ No newline at end of file
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment.ts b/packages/taler-wallet-cli/src/integrationtests/test-payment.ts
new file mode 100644
index 000000000..68713fd9d
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-payment.ts
@@ -0,0 +1,53 @@
+/*
+ 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 { GlobalTestState } from "./harness";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ makeTestPayment,
+} from "./helpers";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runPaymentTest(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" });
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+
+ await wallet.runUntilDone();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-paywall-flow.ts b/packages/taler-wallet-cli/src/integrationtests/test-paywall-flow.ts
new file mode 100644
index 000000000..5eeb7d274
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-paywall-flow.ts
@@ -0,0 +1,233 @@
+/*
+ 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 { GlobalTestState, MerchantPrivateApi } from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+import {
+ PreparePayResultType,
+ codecForMerchantOrderStatusUnpaid,
+ ConfirmPayResultType,
+} from "taler-wallet-core";
+import axios from "axios";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runPaywallFlowTest(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" });
+
+ /**
+ * =========================================================================
+ * Create an order and let the wallet pay under a session ID
+ *
+ * We check along the way that the JSON response to /orders/{order_id}
+ * returns the right thing.
+ * =========================================================================
+ */
+
+ let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ },
+ });
+
+ const firstOrderId = orderResp.order_id;
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-one",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUriOne = orderStatus.taler_pay_uri;
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ let publicOrderStatusUrl = orderStatus.order_status_url;
+
+ let publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await wallet.preparePay({
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ });
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ const proposalId = preparePayResp.proposalId;
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ const confirmPayRes = await wallet.confirmPay({
+ proposalId: proposalId,
+ });
+
+ t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ console.log(publicOrderStatusResp.data);
+
+ if (publicOrderStatusResp.status != 202) {
+ console.log(publicOrderStatusResp.data);
+ throw Error(
+ `expected status 202 (after paying), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ /**
+ * =========================================================================
+ * Now change up the session ID!
+ * =========================================================================
+ */
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ sessionId: "mysession-two",
+ });
+
+ // Should be claimed (not paid!) because of a new session ID
+ t.assertTrue(orderStatus.order_status === "claimed");
+
+ // Pay with new taler://pay URI, which should
+ // have the new session ID!
+ // Wallet should now automatically re-play payment.
+ preparePayResp = await wallet.preparePay({
+ talerPayUri: talerPayUriOne,
+ });
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.AlreadyConfirmed);
+ t.assertTrue(preparePayResp.paid);
+
+ /**
+ * =========================================================================
+ * Now we test re-purchase detection.
+ * =========================================================================
+ */
+
+ orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ // Same fulfillment URL as previously!
+ fulfillment_url: "https://example.com/article42",
+ },
+ });
+
+ const secondOrderId = orderResp.order_id;
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: secondOrderId,
+ sessionId: "mysession-three",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ publicOrderStatusUrl = orderStatus.order_status_url;
+
+ // Here the re-purchase detection should kick in,
+ // and the wallet should re-pay for the old order
+ // under the new session ID (mysession-three).
+ preparePayResp = await wallet.preparePay({
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.AlreadyConfirmed);
+ t.assertTrue(preparePayResp.paid);
+
+ // The first order should now be paid under "mysession-three",
+ // as the wallet did re-purchase detection
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: firstOrderId,
+ sessionId: "mysession-three",
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ // Check that with a completely new session ID, the status would NOT
+ // be paid.
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: firstOrderId,
+ sessionId: "mysession-four",
+ });
+
+ t.assertTrue(orderStatus.order_status === "claimed");
+
+ // Now check if the public status of the new order is correct.
+
+ console.log("requesting public status", publicOrderStatusUrl);
+
+ // Ask the order status of the claimed-but-unpaid order
+ publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
+ validateStatus: () => true,
+ });
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(`expected status 402, but got ${publicOrderStatusResp.status}`);
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.data,
+ );
+
+ console.log(publicOrderStatusResp.data);
+
+ t.assertTrue(pubUnpaidStatus.already_paid_order_id === firstOrderId);
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-refund-auto.ts b/packages/taler-wallet-cli/src/integrationtests/test-refund-auto.ts
new file mode 100644
index 000000000..afac993be
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-refund-auto.ts
@@ -0,0 +1,100 @@
+/*
+ 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 { GlobalTestState, MerchantPrivateApi } from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+import { durationFromSpec } from "taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runRefundAutoTest(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 MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ auto_refund: {
+ d_ms: 3000,
+ },
+ },
+ refund_delay: durationFromSpec({ minutes: 5 }),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: 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 MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ const ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log(ref);
+
+ // The wallet should now automatically pick up the refund.
+ await wallet.runUntilDone();
+
+ const transactions = await wallet.getTransactions();
+ console.log(JSON.stringify(transactions, undefined, 2));
+
+ const transactionTypes = transactions.transactions.map((x) => x.type);
+ t.assertDeepEqual(transactionTypes, ["withdrawal", "payment", "refund"]);
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-refund-gone.ts b/packages/taler-wallet-cli/src/integrationtests/test-refund-gone.ts
new file mode 100644
index 000000000..483e9e06d
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-refund-gone.ts
@@ -0,0 +1,127 @@
+/*
+ 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 { GlobalTestState, MerchantPrivateApi } from "./harness";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ applyTimeTravel,
+} from "./helpers";
+import {
+ durationFromSpec,
+ timestampAddDuration,
+ getTimestampNow,
+ timestampTruncateToSecond,
+} from "taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runRefundGoneTest(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 MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ pay_deadline: timestampTruncateToSecond(
+ timestampAddDuration(
+ getTimestampNow(),
+ durationFromSpec({
+ minutes: 10,
+ }),
+ ),
+ ),
+ },
+ refund_delay: durationFromSpec({ minutes: 1 }),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: 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 MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ console.log(orderStatus);
+
+ await applyTimeTravel(durationFromSpec({ hours: 1 }), { exchange, wallet });
+
+ await exchange.runAggregatorOnce();
+
+ const ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log(ref);
+
+ let rr = await wallet.applyRefund({
+ talerRefundUri: ref.talerRefundUri,
+ });
+
+ t.assertAmountEquals(rr.amountRefundGone, "TESTKUDOS:5");
+ console.log(rr);
+
+ await wallet.runUntilDone();
+
+ let r = await wallet.apiRequest("getBalances", {});
+ console.log(JSON.stringify(r, undefined, 2));
+
+ r = await wallet.apiRequest("getTransactions", {});
+ console.log(JSON.stringify(r, undefined, 2));
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-refund-incremental.ts b/packages/taler-wallet-cli/src/integrationtests/test-refund-incremental.ts
new file mode 100644
index 000000000..d90a4b5f6
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-refund-incremental.ts
@@ -0,0 +1,186 @@
+/*
+ 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 { GlobalTestState, delayMs, MerchantPrivateApi } from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+import { TransactionType, Amounts, durationFromSpec } from "taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runRefundIncrementalTest(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 MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:10",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ refund_delay: durationFromSpec({ minutes: 5 }),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: 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 MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ let ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:2.5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log("first refund increase response", ref);
+
+ {
+ let wr = await wallet.applyRefund({
+ talerRefundUri: ref.talerRefundUri,
+ });
+ console.log(wr);
+ const txs = await wallet.getTransactions();
+ console.log(
+ "transactions after applying first refund:",
+ JSON.stringify(txs, undefined, 2),
+ );
+ }
+
+ // Wait at least a second, because otherwise the increased
+ // refund will be grouped with the previous one.
+ await delayMs(1200);
+
+ ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "bar",
+ orderId: orderResp.order_id,
+ });
+
+ console.log("second refund increase response", ref);
+
+ // Wait at least a second, because otherwise the increased
+ // refund will be grouped with the previous one.
+ await delayMs(1200);
+
+ ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:10",
+ instance: "default",
+ justification: "bar",
+ orderId: orderResp.order_id,
+ });
+
+ console.log("third refund increase response", ref);
+
+ {
+ let wr = await wallet.applyRefund({
+ talerRefundUri: ref.talerRefundUri,
+ });
+ console.log(wr);
+ }
+
+ orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ t.assertAmountEquals(orderStatus.refund_amount, "TESTKUDOS:10");
+
+ console.log(JSON.stringify(orderStatus, undefined, 2));
+
+ await wallet.runUntilDone();
+
+ const bal = await wallet.getBalances();
+ console.log(JSON.stringify(bal, undefined, 2));
+
+ {
+ const txs = await wallet.getTransactions();
+ console.log(JSON.stringify(txs, undefined, 2));
+
+ const txTypes = txs.transactions.map((x) => x.type);
+ t.assertDeepEqual(txTypes, [
+ "withdrawal",
+ "payment",
+ "refund",
+ "refund",
+ "refund",
+ ]);
+
+ for (const tx of txs.transactions) {
+ if (tx.type !== TransactionType.Refund) {
+ continue;
+ }
+ t.assertAmountLeq(tx.amountEffective, tx.amountRaw);
+ }
+
+ const raw = Amounts.sum(
+ txs.transactions
+ .filter((x) => x.type === TransactionType.Refund)
+ .map((x) => x.amountRaw),
+ ).amount;
+
+ t.assertAmountEquals("TESTKUDOS:10", raw);
+
+ const effective = Amounts.sum(
+ txs.transactions
+ .filter((x) => x.type === TransactionType.Refund)
+ .map((x) => x.amountEffective),
+ ).amount;
+
+ t.assertAmountEquals("TESTKUDOS:8.33", effective);
+ }
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-refund.ts b/packages/taler-wallet-cli/src/integrationtests/test-refund.ts
new file mode 100644
index 000000000..12e6b178f
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-refund.ts
@@ -0,0 +1,103 @@
+/*
+ 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 { durationFromSpec } from "taler-wallet-core";
+import { GlobalTestState, MerchantPrivateApi } from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runRefundTest(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 MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ refund_delay: durationFromSpec({ minutes: 5 }),
+ });
+
+ let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: 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 MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ const ref = await MerchantPrivateApi.giveRefund(merchant, {
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log(ref);
+
+ let r = await wallet.apiRequest("applyRefund", {
+ talerRefundUri: ref.talerRefundUri,
+ });
+ t.assertTrue(r.type === "response");
+ console.log(r);
+
+ await wallet.runUntilDone();
+
+ r = await wallet.apiRequest("getBalances", {});
+ console.log(JSON.stringify(r, undefined, 2));
+
+ r = await wallet.apiRequest("getTransactions", {});
+ console.log(JSON.stringify(r, undefined, 2));
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts b/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts
new file mode 100644
index 000000000..ac989855c
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts
@@ -0,0 +1,120 @@
+/*
+ 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 {
+ GlobalTestState,
+ ExchangeService,
+ MerchantService,
+ WalletCli,
+} from "./harness";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ makeTestPayment,
+} from "./helpers";
+import { CoinDumpJson } from "taler-wallet-core";
+
+async function revokeAllWalletCoins(req: {
+ wallet: WalletCli;
+ exchange: ExchangeService;
+ merchant: MerchantService;
+}): Promise<void> {
+ const { wallet, exchange, merchant } = req;
+ const coinDump = await wallet.dumpCoins();
+ console.log(coinDump);
+ const usedDenomHashes = new Set<string>();
+ for (const coin of coinDump.coins) {
+ usedDenomHashes.add(coin.denom_pub_hash);
+ }
+
+ await exchange.stop();
+
+ for (const x of usedDenomHashes.values()) {
+ await exchange.revokeDenomination(x);
+ }
+
+ await exchange.keyup();
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+ await merchant.stop();
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+}
+
+/**
+ * Basic time travel test.
+ */
+export async function runRevocationTest(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:15" });
+
+ await revokeAllWalletCoins({ wallet, exchange, merchant });
+
+ // FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565
+ // is implemented.
+ await wallet.forceUpdateExchange({ exchangeBaseUrl: exchange.baseUrl });
+ await wallet.runUntilDone();
+ await wallet.runUntilDone();
+ const bal = await wallet.getBalances();
+ console.log("wallet balance", bal);
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:10",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPayment(t, { wallet, merchant, order });
+
+ wallet.deleteDatabase();
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
+
+ const coinDump = await wallet.dumpCoins();
+ console.log(coinDump);
+ const coinPubList = coinDump.coins.map((x) => x.coin_pub);
+ await wallet.forceRefresh({
+ coinPubList,
+ });
+ await wallet.runUntilDone();
+
+ await revokeAllWalletCoins({ wallet, exchange, merchant });
+
+ // FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565
+ // is implemented.
+ await wallet.forceUpdateExchange({ exchangeBaseUrl: exchange.baseUrl });
+ await wallet.runUntilDone();
+ await wallet.runUntilDone();
+ {
+ const bal = await wallet.getBalances();
+ console.log("wallet balance", bal);
+ }
+
+ await makeTestPayment(t, { wallet, merchant, order });
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-timetravel-autorefresh.ts b/packages/taler-wallet-cli/src/integrationtests/test-timetravel-autorefresh.ts
new file mode 100644
index 000000000..747f6d75d
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-timetravel-autorefresh.ts
@@ -0,0 +1,203 @@
+/*
+ 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 {
+ ConfirmPayResultType,
+ Duration,
+ durationFromSpec,
+ PendingOperationsResponse,
+ PreparePayResultType,
+} from "taler-wallet-core";
+import { makeNoFeeCoinConfig } from "./denomStructures";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantPrivateApi,
+ MerchantService,
+ setupDb,
+ WalletCli,
+} from "./harness";
+import { startWithdrawViaBank, withdrawViaBank } from "./helpers";
+
+async function applyTimeTravel(
+ timetravelDuration: Duration,
+ s: {
+ exchange?: ExchangeService;
+ merchant?: MerchantService;
+ wallet?: WalletCli;
+ },
+): Promise<void> {
+ if (s.exchange) {
+ await s.exchange.stop();
+ s.exchange.setTimetravel(timetravelDuration);
+ await s.exchange.start();
+ await s.exchange.pingUntilAvailable();
+ }
+
+ if (s.merchant) {
+ await s.merchant.stop();
+ s.merchant.setTimetravel(timetravelDuration);
+ await s.merchant.start();
+ await s.merchant.pingUntilAvailable();
+ }
+
+ if (s.wallet) {
+ s.wallet.setTimetravel(timetravelDuration);
+ }
+}
+
+/**
+ * Basic time travel test.
+ */
+export async function runTimetravelAutorefreshTest(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,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "MyExchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addCoinConfigList(makeNoFeeCoinConfig("TESTKUDOS"));
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ 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);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
+
+ // Travel into the future, the deposit expiration is two years
+ // into the future.
+ await applyTimeTravel(durationFromSpec({ days: 400 }), {
+ wallet,
+ exchange,
+ merchant,
+ });
+
+ await wallet.runUntilDone();
+
+ let p: PendingOperationsResponse;
+ p = await wallet.getPendingOperations();
+
+ console.log("pending operations after first time travel");
+ console.log(JSON.stringify(p, undefined, 2));
+
+ await startWithdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wallet.runUntilDone();
+
+ // Travel into the future, the deposit expiration is two years
+ // into the future.
+ await applyTimeTravel(durationFromSpec({ years: 2, months: 6 }), {
+ wallet,
+ exchange,
+ merchant,
+ });
+
+ await wallet.runUntilDone();
+
+ const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ order: {
+ fulfillment_url: "http://example.com",
+ summary: "foo",
+ amount: "TESTKUDOS:30",
+ },
+ });
+
+ const orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
+ merchant,
+ {
+ orderId: orderResp.order_id,
+ instance: "default",
+ },
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const r = await wallet.preparePay({
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ console.log(r);
+
+ t.assertTrue(r.status === PreparePayResultType.PaymentPossible);
+
+ const cpr = await wallet.confirmPay({
+ proposalId: r.proposalId,
+ });
+
+ t.assertTrue(cpr.type === ConfirmPayResultType.Done);
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-timetravel-withdraw.ts b/packages/taler-wallet-cli/src/integrationtests/test-timetravel-withdraw.ts
new file mode 100644
index 000000000..b4de50b95
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-timetravel-withdraw.ts
@@ -0,0 +1,90 @@
+/*
+ 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 { GlobalTestState } from "./harness";
+import {
+ createSimpleTestkudosEnvironment,
+ withdrawViaBank,
+ startWithdrawViaBank,
+} from "./helpers";
+import { Duration, TransactionType } from "taler-wallet-core";
+
+/**
+ * Basic time travel test.
+ */
+export async function runTimetravelWithdrawTest(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:15" });
+
+ // Travel 400 days into the future,
+ // as the deposit expiration is two years
+ // into the future.
+ const timetravelDuration: Duration = {
+ d_ms: 1000 * 60 * 60 * 24 * 400,
+ };
+
+ await exchange.stop();
+ exchange.setTimetravel(timetravelDuration);
+ await exchange.keyup();
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ await merchant.stop();
+ merchant.setTimetravel(timetravelDuration);
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ // This should fail, as the wallet didn't time travel yet.
+ await startWithdrawViaBank(t, {
+ wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ // Check that transactions are correct for the failed withdrawal
+ {
+ await wallet.runUntilDone({ maxRetries: 5 });
+ const transactions = await wallet.getTransactions();
+ console.log(transactions);
+ const types = transactions.transactions.map((x) => x.type);
+ t.assertDeepEqual(types, ["withdrawal", "withdrawal"]);
+ const wtrans = transactions.transactions[1];
+ t.assertTrue(wtrans.type === TransactionType.Withdrawal);
+ t.assertTrue(wtrans.pending);
+ }
+
+ // Now we also let the wallet time travel
+
+ wallet.setTimetravel(timetravelDuration);
+
+ // This doesn't work yet, see https://bugs.taler.net/n/6585
+
+ // await wallet.runUntilDone({ maxRetries: 5 });
+} \ No newline at end of file
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-tipping.ts b/packages/taler-wallet-cli/src/integrationtests/test-tipping.ts
new file mode 100644
index 000000000..01ec6c1bb
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-tipping.ts
@@ -0,0 +1,127 @@
+/*
+ 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 {
+ GlobalTestState,
+ MerchantPrivateApi,
+ BankApi,
+} from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runTippingTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ exchangeBankAccount,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ const mbu = await BankApi.createRandomBankUser(bank);
+
+ const tipReserveResp = await MerchantPrivateApi.createTippingReserve(
+ merchant,
+ "default",
+ {
+ exchange_url: exchange.baseUrl,
+ initial_balance: "TESTKUDOS:10",
+ wire_method: "x-taler-bank",
+ },
+ );
+
+ console.log("tipReserveResp:", tipReserveResp);
+
+ t.assertDeepEqual(
+ tipReserveResp.payto_uri,
+ exchangeBankAccount.accountPaytoUri,
+ );
+
+ await BankApi.adminAddIncoming(bank, {
+ amount: "TESTKUDOS:10",
+ debitAccountPayto: mbu.accountPaytoUri,
+ exchangeBankAccount,
+ reservePub: tipReserveResp.reserve_pub,
+ });
+
+ await exchange.runWirewatchOnce();
+ await merchant.stop();
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ const r = await MerchantPrivateApi.queryTippingReserves(merchant, "default");
+ console.log("tipping reserves:", JSON.stringify(r, undefined, 2));
+
+ t.assertTrue(r.reserves.length === 1);
+ t.assertDeepEqual(
+ r.reserves[0].exchange_initial_amount,
+ r.reserves[0].merchant_initial_amount,
+ );
+
+ const tip = await MerchantPrivateApi.giveTip(merchant, "default", {
+ amount: "TESTKUDOS:5",
+ justification: "why not?",
+ next_url: "https://example.com/after-tip",
+ });
+
+ console.log("created tip", tip);
+
+ const doTip = async (): Promise<void> => {
+ const ptr = await wallet.prepareTip({
+ talerTipUri: tip.taler_tip_uri,
+ });
+
+ console.log(ptr);
+
+ t.assertAmountEquals(ptr.tipAmountRaw, "TESTKUDOS:5");
+ t.assertAmountEquals(ptr.tipAmountEffective, "TESTKUDOS:4.85");
+
+ await wallet.acceptTip({
+ walletTipId: ptr.walletTipId,
+ });
+
+ await wallet.runUntilDone();
+
+ const bal = await wallet.getBalances();
+
+ console.log(bal);
+
+ t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:4.85");
+
+ const txns = await wallet.getTransactions();
+
+ console.log("Transactions:", JSON.stringify(txns, undefined, 2));
+
+ t.assertDeepEqual(txns.transactions[0].type, "tip");
+ t.assertDeepEqual(txns.transactions[0].pending, false);
+ t.assertAmountEquals(
+ txns.transactions[0].amountEffective,
+ "TESTKUDOS:4.85",
+ );
+ t.assertAmountEquals(txns.transactions[0].amountRaw, "TESTKUDOS:5.0");
+ };
+
+ // Check twice so make sure tip handling is idempotent
+ await doTip();
+ await doTip();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-wallettesting.ts b/packages/taler-wallet-cli/src/integrationtests/test-wallettesting.ts
new file mode 100644
index 000000000..cdb954858
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-wallettesting.ts
@@ -0,0 +1,87 @@
+/*
+ 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/>
+ */
+
+/**
+ * Integration test for the wallet testing functionality used by the exchange
+ * test cases.
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalTestState } from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWallettestingTest(t: GlobalTestState) {
+ const {
+ wallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ await wallet.runIntegrationTest({
+ amountToSpend: "TESTKUDOS:5",
+ amountToWithdraw: "TESTKUDOS:10",
+ bankBaseUrl: bank.baseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ merchantApiKey: "sandbox",
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ });
+
+ let txns = await wallet.getTransactions();
+ console.log(JSON.stringify(txns, undefined, 2));
+ let txTypes = txns.transactions.map((x) => x.type);
+
+ t.assertDeepEqual(txTypes, [
+ "withdrawal",
+ "payment",
+ "withdrawal",
+ "payment",
+ "refund",
+ "payment",
+ ]);
+
+ wallet.deleteDatabase();
+
+ await wallet.withdrawTestBalance({
+ amount: "TESTKUDOS:10",
+ bankBaseUrl: bank.baseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ await wallet.runUntilDone();
+
+ await wallet.testPay({
+ amount: "TESTKUDOS:5",
+ merchantApiKey: "sandbox",
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ summary: "foo",
+ });
+
+ await wallet.runUntilDone();
+
+ txns = await wallet.getTransactions();
+ console.log(JSON.stringify(txns, undefined, 2));
+ txTypes = txns.transactions.map((x) => x.type);
+
+ t.assertDeepEqual(txTypes, ["withdrawal", "payment"]);
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-abort-bank.ts b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-abort-bank.ts
new file mode 100644
index 000000000..9a4e6004b
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-abort-bank.ts
@@ -0,0 +1,67 @@
+/*
+ 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 { GlobalTestState, BankApi, BankAccessApi } from "./harness";
+import { createSimpleTestkudosEnvironment } from "./helpers";
+import { codecForBalancesResponse, TalerErrorCode } from "taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWithdrawalAbortBankTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange } = await createSimpleTestkudosEnvironment(t);
+
+ // Create a withdrawal operation
+
+ const user = await BankApi.createRandomBankUser(bank);
+ const wop = await BankAccessApi.createWithdrawalOperation(
+ bank,
+ 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 BankApi.abortWithdrawalOperation(bank, user, wop);
+
+ // Withdraw
+
+ const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+ t.assertTrue(r2.type === "error");
+ t.assertTrue(
+ r2.error.code ===
+ TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
+ );
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts
new file mode 100644
index 000000000..89f5104a9
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts
@@ -0,0 +1,71 @@
+/*
+ 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 { GlobalTestState, BankApi, BankAccessApi } from "./harness";
+import { createSimpleTestkudosEnvironment } from "./helpers";
+import { codecForBalancesResponse } from "taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { wallet, bank, exchange } = await createSimpleTestkudosEnvironment(t);
+
+ // Create a withdrawal operation
+
+ const user = await BankApi.createRandomBankUser(bank);
+ const wop = await BankAccessApi.createWithdrawalOperation(
+ bank,
+ 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 BankApi.confirmWithdrawalOperation(bank, 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 = codecForBalancesResponse().decode(balApiResp.result);
+ t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts
new file mode 100644
index 000000000..ef0db4a19
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts
@@ -0,0 +1,78 @@
+/*
+ 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 { GlobalTestState, BankApi } from "./harness";
+import { createSimpleTestkudosEnvironment } from "./helpers";
+import { CoreApiResponse } from "taler-wallet-core";
+import { codecForBalancesResponse } from "taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runTestWithdrawalManualTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ wallet,
+ bank,
+ exchange,
+ exchangeBankAccount,
+ } = await createSimpleTestkudosEnvironment(t);
+
+ // Create a withdrawal operation
+
+ const user = await BankApi.createRandomBankUser(bank);
+
+ let wresp: CoreApiResponse;
+
+ wresp = await wallet.apiRequest("addExchange", {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ t.assertTrue(wresp.type === "response");
+
+ wresp = await wallet.apiRequest("acceptManualWithdrawal", {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10",
+ });
+
+ t.assertTrue(wresp.type === "response");
+
+ const reservePub: string = (wresp.result as any).reservePub;
+
+ await BankApi.adminAddIncoming(bank, {
+ exchangeBankAccount,
+ amount: "TESTKUDOS:10",
+ debitAccountPayto: user.accountPaytoUri,
+ reservePub: reservePub,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await wallet.runUntilDone();
+
+ // Check balance
+
+ const balApiResp = await wallet.apiRequest("getBalances", {});
+ t.assertTrue(balApiResp.type === "response");
+ const balResp = codecForBalancesResponse().decode(balApiResp.result);
+ t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
+
+ await t.shutdown();
+}
diff --git a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
new file mode 100644
index 000000000..d9804562e
--- /dev/null
+++ b/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
@@ -0,0 +1,176 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 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/>
+ */
+
+import { GlobalTestState, runTestWithState, TestRunResult } from "./harness";
+import { runPaymentTest } from "./test-payment";
+import * as fs from "fs";
+import * as path from "path";
+import * as os from "os";
+import { runBankApiTest } from "./test-bank-api";
+import { runClaimLoopTest } from "./test-claim-loop";
+import { runExchangeManagementTest } from "./test-exchange-management";
+import { runFeeRegressionTest } from "./test-fee-regression";
+import { runMerchantLongpollingTest } from "./test-merchant-longpolling";
+import { runMerchantRefundApiTest } from "./test-merchant-refund-api";
+import { runPayAbortTest } from "./test-pay-abort";
+import { runPayPaidTest } from "./test-pay-paid";
+import { runPaymentClaimTest } from "./test-payment-claim";
+import { runPaymentFaultTest } from "./test-payment-fault";
+import { runPaymentIdempotencyTest } from "./test-payment-idempotency";
+import { runPaymentMultipleTest } from "./test-payment-multiple";
+import { runPaymentTransientTest } from "./test-payment-transient";
+import { runPaywallFlowTest } from "./test-paywall-flow";
+import { runRefundAutoTest } from "./test-refund-auto";
+import { runRefundGoneTest } from "./test-refund-gone";
+import { runRefundIncrementalTest } from "./test-refund-incremental";
+import { runRefundTest } from "./test-refund";
+import { runRevocationTest } from "./test-revocation";
+import { runTimetravelAutorefreshTest } from "./test-timetravel-autorefresh";
+import { runTimetravelWithdrawTest } from "./test-timetravel-withdraw";
+import { runTippingTest } from "./test-tipping";
+import { runWallettestingTest } from "./test-wallettesting";
+import { runTestWithdrawalManualTest } from "./test-withdrawal-manual";
+import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank";
+import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated";
+import M from "minimatch";
+
+/**
+ * Test runner.
+ */
+
+/**
+ * Spec for one test.
+ */
+interface TestMainFunction {
+ (t: GlobalTestState): Promise<void>;
+}
+
+const allTests: TestMainFunction[] = [
+ runBankApiTest,
+ runClaimLoopTest,
+ runExchangeManagementTest,
+ runFeeRegressionTest,
+ runMerchantLongpollingTest,
+ runMerchantRefundApiTest,
+ runPayAbortTest,
+ runPayPaidTest,
+ runPaymentClaimTest,
+ runPaymentFaultTest,
+ runPaymentIdempotencyTest,
+ runPaymentMultipleTest,
+ runPaymentTransientTest,
+ runPaywallFlowTest,
+ runRefundAutoTest,
+ runRefundGoneTest,
+ runRefundIncrementalTest,
+ runRefundTest,
+ runRevocationTest,
+ runTimetravelAutorefreshTest,
+ runTimetravelWithdrawTest,
+ runTippingTest,
+ runWallettestingTest,
+ runWithdrawalAbortBankTest,
+ runWithdrawalBankIntegratedTest,
+ runWallettestingTest,
+ runPaymentTest,
+];
+
+export interface TestRunSpec {
+ include_pattern?: string;
+}
+
+export interface TestInfo {
+ name: string;
+}
+
+function updateCurrentSymlink(testDir: string): void {
+ const currLink = path.join(os.tmpdir(), "taler-integrationtests-current");
+ try {
+ fs.unlinkSync(currLink);
+ } catch (e) {
+ // Ignore
+ }
+ try {
+ fs.symlinkSync(testDir, currLink);
+ } catch (e) {
+ console.log(e);
+ // Ignore
+ }
+}
+
+export function getTestName(tf: TestMainFunction): string {
+ const res = tf.name.match(/run([a-zA-Z0-9]*)Test/);
+ if (!res) {
+ throw Error("invalid test name, must be 'run${NAME}Test'");
+ }
+ return res[1]
+ .replace(/[a-z0-9][A-Z]/, (x) => {
+ return x[0] + "-" + x[1];
+ })
+ .toLowerCase();
+}
+
+export async function runTests(spec: TestRunSpec) {
+ const testRootDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "taler-integrationtests-"),
+ );
+ updateCurrentSymlink(testRootDir);
+ console.log("testsuite root directory: ", testRootDir);
+
+ let numTotal = 0;
+ let numFail = 0;
+ let numSkip = 0;
+ let numPass = 0;
+
+ const testResults: TestRunResult[] = [];
+
+ for (const [n, testCase] of allTests.entries()) {
+ const testName = getTestName(testCase);
+ if (spec.include_pattern && !M(testName, spec.include_pattern)) {
+ continue;
+ }
+ const testDir = path.join(testRootDir, testName);
+ fs.mkdirSync(testDir);
+ console.log(`running test ${testName}`);
+ const gc = new GlobalTestState({
+ testDir,
+ });
+ const result = await runTestWithState(gc, testCase, testName);
+ testResults.push(result);
+ console.log(result);
+ numTotal++;
+ if (result.status === "fail") {
+ numFail++;
+ } else if (result.status === "skip") {
+ numSkip++;
+ } else if (result.status === "pass") {
+ numPass++;
+ }
+ }
+ const resultsFile = path.join(testRootDir, "results.json");
+ fs.writeFileSync(
+ path.join(testRootDir, "results.json"),
+ JSON.stringify({ testResults }, undefined, 2),
+ );
+ console.log(`See ${resultsFile} for details`);
+ console.log(`Passed: ${numPass}/${numTotal}`);
+}
+
+export function getTestInfo(): TestInfo[] {
+ return allTests.map((x) => ({
+ name: getTestName(x),
+ }));
+}