towards integration tests with fault injection

This commit is contained in:
Florian Dold 2020-08-06 00:30:36 +05:30
parent a8f03d3dd1
commit 82a2437c09
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
30 changed files with 2536 additions and 34 deletions

4
.gitignore vendored
View File

@ -7,7 +7,8 @@ tsconfig.tsbuildinfo
# GNU-style build system # GNU-style build system
/configure /configure
build-system/config.mk /build-system/config.mk
/Makefile
# Editor files # Editor files
\#*\# \#*\#
@ -20,3 +21,4 @@ build-scripts/
# Git worktree of pre-built wallet files # Git worktree of pre-built wallet files
prebuilt/ prebuilt/

View File

@ -0,0 +1,43 @@
{
"name": "taler-integrationtests",
"version": "0.0.1",
"description": "Integration tests and fault injection for GNU Taler components",
"main": "index.js",
"scripts": {
"compile": "tsc",
"test": "tsc && ava"
},
"author": "Florian Dold <dold@taler.net>",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@ava/typescript": "^1.1.1",
"ava": "^3.11.1",
"esm": "^3.2.25",
"source-map-support": "^0.5.19",
"ts-node": "^8.10.2"
},
"dependencies": {
"axios": "^0.19.2",
"taler-wallet-core": "workspace:*",
"tslib": "^2.0.0",
"typescript": "^3.9.7"
},
"ava": {
"require": [
"esm"
],
"files": [
"src/**/test-*"
],
"typescript": {
"extensions": [
"js",
"ts",
"tsx"
],
"rewritePaths": {
"src/": "lib/"
}
}
}
}

View File

@ -0,0 +1,222 @@
/*
This file is part of GNU Taler
(C) 2020 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Fault injection proxy.
*
* @author Florian Dold <dold@taler.net>
*/
/**
* Imports
*/
import * as http from "http";
import { URL } from "url";
import {
GlobalTestState,
ExchangeService,
BankService,
ExchangeServiceInterface,
} from "./harness";
export interface FaultProxyConfig {
inboundPort: number;
targetPort: number;
}
/**
* Fault injection context. Modified by fault injection functions.
*/
export interface FaultInjectionRequestContext {
requestUrl: string;
method: string;
requestHeaders: Record<string, string | string[] | undefined>;
requestBody?: Buffer;
dropRequest: boolean;
}
export interface FaultInjectionResponseContext {
request: FaultInjectionRequestContext;
statusCode: number;
responseHeaders: Record<string, string | string[] | undefined>;
responseBody: Buffer | undefined;
dropResponse: boolean;
}
export interface FaultSpec {
modifyRequest?: (ctx: FaultInjectionRequestContext) => void;
modifyResponse?: (ctx: FaultInjectionResponseContext) => void;
}
export class FaultProxy {
constructor(
private globalTestState: GlobalTestState,
private faultProxyConfig: FaultProxyConfig,
) {}
private currentFaultSpecs: FaultSpec[] = [];
start() {
const server = http.createServer((req, res) => {
const requestChunks: Buffer[] = [];
const requestUrl = `http://locahost:${this.faultProxyConfig.inboundPort}${req.url}`;
console.log("request for", new URL(requestUrl));
req.on("data", (chunk) => {
requestChunks.push(chunk);
});
req.on("end", () => {
console.log("end of data");
let requestBuffer: Buffer | undefined;
if (requestChunks.length > 0) {
requestBuffer = Buffer.concat(requestChunks);
}
console.log("full request body", requestBuffer);
const faultReqContext: FaultInjectionRequestContext = {
dropRequest: false,
method: req.method!!,
requestHeaders: req.headers,
requestUrl,
requestBody: requestBuffer,
};
for (const faultSpec of this.currentFaultSpecs) {
if (faultSpec.modifyRequest) {
faultSpec.modifyRequest(faultReqContext);
}
}
if (faultReqContext.dropRequest) {
res.destroy();
return;
}
const faultedUrl = new URL(faultReqContext.requestUrl);
const proxyRequest = http.request({
method: faultReqContext.method,
host: "localhost",
port: this.faultProxyConfig.targetPort,
path: faultedUrl.pathname + faultedUrl.search,
headers: faultReqContext.requestHeaders,
});
console.log(
`proxying request to target path '${
faultedUrl.pathname + faultedUrl.search
}'`,
);
if (faultReqContext.requestBody) {
proxyRequest.write(faultReqContext.requestBody);
}
proxyRequest.end();
proxyRequest.on("response", (proxyResp) => {
console.log("gotten response from target", proxyResp.statusCode);
const respChunks: Buffer[] = [];
proxyResp.on("data", (proxyRespData) => {
respChunks.push(proxyRespData);
});
proxyResp.on("end", () => {
console.log("end of target response");
let responseBuffer: Buffer | undefined;
if (respChunks.length > 0) {
responseBuffer = Buffer.concat(respChunks);
}
const faultRespContext: FaultInjectionResponseContext = {
request: faultReqContext,
dropResponse: false,
responseBody: responseBuffer,
responseHeaders: proxyResp.headers,
statusCode: proxyResp.statusCode!!,
};
for (const faultSpec of this.currentFaultSpecs) {
const modResponse = faultSpec.modifyResponse;
if (modResponse) {
modResponse(faultRespContext);
}
}
if (faultRespContext.dropResponse) {
req.destroy();
return;
}
if (faultRespContext.responseBody) {
// We must accomodate for potentially changed content length
faultRespContext.responseHeaders[
"content-length"
] = `${faultRespContext.responseBody.byteLength}`;
}
console.log("writing response head");
res.writeHead(
faultRespContext.statusCode,
http.STATUS_CODES[faultRespContext.statusCode],
faultRespContext.responseHeaders,
);
if (faultRespContext.responseBody) {
res.write(faultRespContext.responseBody);
}
res.end();
});
});
});
});
server.listen(this.faultProxyConfig.inboundPort);
this.globalTestState.servers.push(server);
}
addFault(f: FaultSpec) {
this.currentFaultSpecs.push(f);
}
clearFault() {
this.currentFaultSpecs = [];
}
}
export class FaultInjectedExchangeService implements ExchangeServiceInterface {
baseUrl: string;
port: number;
faultProxy: FaultProxy;
get name(): string {
return this.innerExchange.name;
}
get masterPub(): string {
return this.innerExchange.masterPub;
}
private innerExchange: ExchangeService;
constructor(
t: GlobalTestState,
e: ExchangeService,
proxyInboundPort: number,
) {
this.innerExchange = e;
this.faultProxy = new FaultProxy(t, {
inboundPort: proxyInboundPort,
targetPort: e.port,
});
this.faultProxy.start();
const exchangeUrl = new URL(e.baseUrl);
exchangeUrl.port = `${proxyInboundPort}`;
this.baseUrl = exchangeUrl.href;
this.port = proxyInboundPort;
}
}

View File

@ -0,0 +1,907 @@
/*
This file is part of GNU Taler
(C) 2020 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Test harness for various GNU Taler components.
* Also provides a fault-injection proxy.
*
* @author Florian Dold <dold@taler.net>
*/
/**
* Imports
*/
import * as util from "util";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import * as http from "http";
import { ChildProcess, spawn } from "child_process";
import {
Configuration,
walletCoreApi,
codec,
AmountJson,
Amounts,
} from "taler-wallet-core";
import { URL } from "url";
import axios from "axios";
import { talerCrypto, time } from "taler-wallet-core";
import { codecForMerchantOrderPrivateStatusResponse, codecForPostOrderResponse, PostOrderRequest, PostOrderResponse } from "./merchantApiTypes";
const exec = util.promisify(require("child_process").exec);
async function delay(ms: number): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(), ms);
});
}
interface WaitResult {
code: number | null;
signal: NodeJS.Signals | null;
}
/**
* Run a shell command, return stdout.
*/
export async function sh(command: string): Promise<string> {
console.log("runing command");
console.log(command);
return new Promise((resolve, reject) => {
const stdoutChunks: Buffer[] = [];
const proc = spawn(command, {
stdio: ["inherit", "pipe", "inherit"],
shell: true,
});
proc.stdout.on("data", (x) => {
console.log("child process got data chunk");
if (x instanceof Buffer) {
stdoutChunks.push(x);
} else {
throw Error("unexpected data chunk type");
}
});
proc.on("exit", (code) => {
console.log("child process exited");
if (code != 0) {
reject(Error(`Unexpected exit code ${code} for '${command}'`));
return;
}
const b = Buffer.concat(stdoutChunks).toString("utf-8");
resolve(b);
});
proc.on("error", () => {
reject(Error("Child process had error"));
});
});
}
export class ProcessWrapper {
private waitPromise: Promise<WaitResult>;
constructor(public proc: ChildProcess) {
this.waitPromise = new Promise((resolve, reject) => {
proc.on("exit", (code, signal) => {
resolve({ code, signal });
});
proc.on("error", (err) => {
reject(err);
});
});
}
wait(): Promise<WaitResult> {
return this.waitPromise;
}
}
export function makeTempDir(): Promise<string> {
return new Promise((resolve, reject) => {
fs.mkdtemp(
path.join(os.tmpdir(), "taler-integrationtest-"),
(err, directory) => {
if (err) {
reject(err);
return;
}
resolve(directory);
console.log(directory);
},
);
});
}
interface CoinConfig {
name: string;
value: string;
durationWithdraw: string;
durationSpend: string;
durationLegal: string;
feeWithdraw: string;
feeDeposit: string;
feeRefresh: string;
feeRefund: string;
rsaKeySize: number;
}
const coinCommon = {
durationLegal: "3 years",
durationSpend: "2 years",
durationWithdraw: "7 days",
rsaKeySize: 1024,
};
const coin_ct1 = (curr: string): CoinConfig => ({
...coinCommon,
name: `${curr}_ct1`,
value: `${curr}:0.01`,
feeDeposit: `${curr}:0.00`,
feeRefresh: `${curr}:0.01`,
feeRefund: `${curr}:0.00`,
feeWithdraw: `${curr}:0.01`,
});
const coin_ct10 = (curr: string): CoinConfig => ({
...coinCommon,
name: `${curr}_ct10`,
value: `${curr}:0.10`,
feeDeposit: `${curr}:0.01`,
feeRefresh: `${curr}:0.01`,
feeRefund: `${curr}:0.00`,
feeWithdraw: `${curr}:0.01`,
});
const coin_u1 = (curr: string): CoinConfig => ({
...coinCommon,
name: `${curr}_u1`,
value: `${curr}:1`,
feeDeposit: `${curr}:0.02`,
feeRefresh: `${curr}:0.02`,
feeRefund: `${curr}:0.02`,
feeWithdraw: `${curr}:0.02`,
});
const coin_u2 = (curr: string): CoinConfig => ({
...coinCommon,
name: `${curr}_u2`,
value: `${curr}:2`,
feeDeposit: `${curr}:0.02`,
feeRefresh: `${curr}:0.02`,
feeRefund: `${curr}:0.02`,
feeWithdraw: `${curr}:0.02`,
});
const coin_u4 = (curr: string): CoinConfig => ({
...coinCommon,
name: `${curr}_u4`,
value: `${curr}:4`,
feeDeposit: `${curr}:0.02`,
feeRefresh: `${curr}:0.02`,
feeRefund: `${curr}:0.02`,
feeWithdraw: `${curr}:0.02`,
});
const coin_u8 = (curr: string): CoinConfig => ({
...coinCommon,
name: `${curr}_u8`,
value: `${curr}:8`,
feeDeposit: `${curr}:0.16`,
feeRefresh: `${curr}:0.16`,
feeRefund: `${curr}:0.16`,
feeWithdraw: `${curr}:0.16`,
});
const coin_u10 = (curr: string): CoinConfig => ({
...coinCommon,
name: `${curr}_u10`,
value: `${curr}:10`,
feeDeposit: `${curr}:0.2`,
feeRefresh: `${curr}:0.2`,
feeRefund: `${curr}:0.2`,
feeWithdraw: `${curr}:0.2`,
});
export class GlobalTestParams {
testDir: string;
}
export class GlobalTestState {
testDir: string;
procs: ProcessWrapper[];
servers: http.Server[];
constructor(params: GlobalTestParams) {
this.testDir = params.testDir;
this.procs = [];
this.servers = [];
process.on("SIGINT", () => this.shutdownSync());
process.on("SIGTERM", () => this.shutdownSync());
process.on("unhandledRejection", () => this.shutdownSync());
process.on("uncaughtException", () => this.shutdownSync());
}
assertTrue(b: boolean): asserts b {
if (!b) {
throw Error("test assertion failed");
}
}
assertAmountEquals(
amtExpected: string | AmountJson,
amtActual: string | AmountJson,
): void {
let ja1: AmountJson;
let ja2: AmountJson;
if (typeof amtExpected === "string") {
ja1 = Amounts.parseOrThrow(amtExpected);
} else {
ja1 = amtExpected;
}
if (typeof amtActual === "string") {
ja2 = Amounts.parseOrThrow(amtActual);
} else {
ja2 = amtActual;
}
if (Amounts.cmp(ja1, ja2) != 0) {
throw Error(
`test assertion failed: expected ${Amounts.stringify(
ja1,
)} but got ${Amounts.stringify(ja2)}`,
);
}
}
private shutdownSync(): void {
for (const s of this.servers) {
s.close();
s.removeAllListeners();
}
for (const p of this.procs) {
if (p.proc.exitCode == null) {
p.proc.kill("SIGTERM");
} else {
}
}
console.log("*** test harness interrupted");
console.log("*** test state can be found under", this.testDir);
process.exit(1);
}
spawnService(command: string, logName: string): ProcessWrapper {
const proc = spawn(command, {
shell: true,
stdio: ["inherit", "pipe", "pipe"],
});
const stderrLogFileName = this.testDir + `/${logName}-stderr.log`;
const stderrLog = fs.createWriteStream(stderrLogFileName, {
flags: "a",
});
proc.stderr.pipe(stderrLog);
const stdoutLogFileName = this.testDir + `/${logName}-stdout.log`;
const stdoutLog = fs.createWriteStream(stdoutLogFileName, {
flags: "a",
});
proc.stdout.pipe(stdoutLog);
const procWrap = new ProcessWrapper(proc);
this.procs.push(procWrap);
return procWrap;
}
async terminate(): Promise<void> {
console.log("terminating");
for (const s of this.servers) {
s.close();
s.removeAllListeners();
}
for (const p of this.procs) {
if (p.proc.exitCode == null) {
console.log("killing process", p.proc.pid);
p.proc.kill("SIGTERM");
await p.wait();
}
}
}
}
export interface TalerConfigSection {
options: Record<string, string | undefined>;
}
export interface TalerConfig {
sections: Record<string, TalerConfigSection>;
}
export interface DbInfo {
connStr: string;
dbname: string;
}
export async function setupDb(gc: GlobalTestState): Promise<DbInfo> {
const dbname = "taler-integrationtest";
await exec(`dropdb "${dbname}" || true`);
await exec(`createdb "${dbname}"`);
return {
connStr: `postgres:///${dbname}`,
dbname,
};
}
export interface BankConfig {
currency: string;
httpPort: number;
database: string;
suggestedExchange: string | undefined;
suggestedExchangePayto: string | undefined;
allowRegistrations: boolean;
}
function setPaths(config: Configuration, home: string) {
config.setString("paths", "taler_home", home);
config.setString(
"paths",
"taler_data_home",
"$TALER_HOME/.local/share/taler/",
);
config.setString("paths", "taler_config_home", "$TALER_HOME/.config/taler/");
config.setString("paths", "taler_cache_home", "$TALER_HOME/.config/taler/");
config.setString(
"paths",
"taler_runtime_dir",
"${TMPDIR:-${TMP:-/tmp}}/taler-system-runtime/",
);
}
function setCoin(config: Configuration, c: CoinConfig) {
const s = `coin_${c.name}`;
config.setString(s, "value", c.value);
config.setString(s, "duration_withdraw", c.durationWithdraw);
config.setString(s, "duration_spend", c.durationSpend);
config.setString(s, "duration_legal", c.durationLegal);
config.setString(s, "fee_deposit", c.feeDeposit);
config.setString(s, "fee_withdraw", c.feeWithdraw);
config.setString(s, "fee_refresh", c.feeRefresh);
config.setString(s, "fee_refund", c.feeRefund);
config.setString(s, "rsa_keysize", `${c.rsaKeySize}`);
}
export class BankService {
proc: ProcessWrapper | undefined;
static async create(
gc: GlobalTestState,
bc: BankConfig,
): Promise<BankService> {
const config = new Configuration();
setPaths(config, gc.testDir + "/talerhome");
config.setString("taler", "currency", bc.currency);
config.setString("bank", "database", bc.database);
config.setString("bank", "http_port", `${bc.httpPort}`);
config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
config.setString(
"bank",
"allow_registrations",
bc.allowRegistrations ? "yes" : "no",
);
if (bc.suggestedExchange) {
config.setString("bank", "suggested_exchange", bc.suggestedExchange);
}
if (bc.suggestedExchangePayto) {
config.setString(
"bank",
"suggested_exchange_payto",
bc.suggestedExchangePayto,
);
}
const cfgFilename = gc.testDir + "/bank.conf";
config.write(cfgFilename);
return new BankService(gc, bc, cfgFilename);
}
get port() {
return this.bankConfig.httpPort;
}
private constructor(
private globalTestState: GlobalTestState,
private bankConfig: BankConfig,
private configFile: string,
) {}
async start(): Promise<void> {
this.proc = this.globalTestState.spawnService(
`taler-bank-manage -c "${this.configFile}" serve-http`,
"bank",
);
}
async pingUntilAvailable(): Promise<void> {
const url = `http://localhost:${this.bankConfig.httpPort}/config`;
while (true) {
try {
console.log("pinging bank");
const resp = await axios.get(url);
return;
} catch (e) {
console.log("bank not ready:", e.toString());
await delay(1000);
}
}
}
async createAccount(username: string, password: string): Promise<void> {
const url = `http://localhost:${this.bankConfig.httpPort}/testing/register`;
await axios.post(url, {
username,
password,
});
}
async createRandomBankUser(): Promise<BankUser> {
const bankUser: BankUser = {
username:
"user-" + talerCrypto.encodeCrock(talerCrypto.getRandomBytes(10)),
password: "pw-" + talerCrypto.encodeCrock(talerCrypto.getRandomBytes(10)),
};
await this.createAccount(bankUser.username, bankUser.password);
return bankUser;
}
async createWithdrawalOperation(
bankUser: BankUser,
amount: string,
): Promise<WithdrawalOperationInfo> {
const url = `http://localhost:${this.bankConfig.httpPort}/accounts/${bankUser.username}/withdrawals`;
const resp = await axios.post(
url,
{
amount,
},
{
auth: bankUser,
},
);
return codecForWithdrawalOperationInfo().decode(resp.data);
}
async confirmWithdrawalOperation(
bankUser: BankUser,
wopi: WithdrawalOperationInfo,
): Promise<void> {
const url = `http://localhost:${this.bankConfig.httpPort}/accounts/${bankUser.username}/withdrawals/${wopi.withdrawal_id}/confirm`;
await axios.post(
url,
{},
{
auth: bankUser,
},
);
}
}
export interface BankUser {
username: string;
password: string;
}
export interface WithdrawalOperationInfo {
withdrawal_id: string;
taler_withdraw_uri: string;
}
const codecForWithdrawalOperationInfo = (): codec.Codec<
WithdrawalOperationInfo
> =>
codec
.makeCodecForObject<WithdrawalOperationInfo>()
.property("withdrawal_id", codec.codecForString)
.property("taler_withdraw_uri", codec.codecForString)
.build("WithdrawalOperationInfo");
export interface ExchangeConfig {
name: string;
currency: string;
roundUnit?: string;
httpPort: number;
database: string;
}
export interface ExchangeServiceInterface {
readonly baseUrl: string;
readonly port: number;
readonly name: string;
readonly masterPub: string;
}
export class ExchangeService implements ExchangeServiceInterface {
static create(gc: GlobalTestState, e: ExchangeConfig) {
const config = new Configuration();
config.setString("taler", "currency", e.currency);
config.setString(
"taler",
"currency_round_unit",
e.roundUnit ?? `${e.currency}:0.01`,
);
setPaths(config, gc.testDir + "/talerhome");
config.setString(
"exchange",
"keydir",
"${TALER_DATA_HOME}/exchange/live-keys/",
);
config.setString(
"exchage",
"revocation_dir",
"${TALER_DATA_HOME}/exchange/revocations",
);
config.setString("exchange", "max_keys_caching", "forever");
config.setString("exchange", "db", "postgres");
config.setString(
"exchange",
"master_priv_file",
"${TALER_DATA_HOME}/exchange/offline-keys/master.priv",
);
config.setString("exchange", "serve", "tcp");
config.setString("exchange", "port", `${e.httpPort}`);
config.setString("exchange", "port", `${e.httpPort}`);
config.setString("exchange", "signkey_duration", "4 weeks");
config.setString("exchange", "legal_duraction", "2 years");
config.setString("exchange", "lookahead_sign", "32 weeks 1 day");
config.setString("exchange", "lookahead_provide", "4 weeks 1 day");
for (let i = 2020; i < 2029; i++) {
config.setString(
"fees-x-taler-bank",
`wire-fee-${i}`,
`${e.currency}:0.01`,
);
config.setString(
"fees-x-taler-bank",
`closing-fee-${i}`,
`${e.currency}:0.01`,
);
}
config.setString("exchangedb-postgres", "config", e.database);
setCoin(config, coin_ct1(e.currency));
setCoin(config, coin_ct10(e.currency));
setCoin(config, coin_u1(e.currency));
setCoin(config, coin_u2(e.currency));
setCoin(config, coin_u4(e.currency));
setCoin(config, coin_u8(e.currency));
setCoin(config, coin_u10(e.currency));
const exchangeMasterKey = talerCrypto.createEddsaKeyPair();
config.setString(
"exchange",
"master_public_key",
talerCrypto.encodeCrock(exchangeMasterKey.eddsaPub),
);
const masterPrivFile = config
.getPath("exchange", "master_priv_file")
.required();
fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true });
fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv));
console.log("writing key to", masterPrivFile);
console.log("pub is", talerCrypto.encodeCrock(exchangeMasterKey.eddsaPub));
console.log(
"priv is",
talerCrypto.encodeCrock(exchangeMasterKey.eddsaPriv),
);
const cfgFilename = gc.testDir + `/exchange-${e.name}.conf`;
config.write(cfgFilename);
return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey);
}
get masterPub() {
return talerCrypto.encodeCrock(this.keyPair.eddsaPub);
}
get port() {
return this.exchangeConfig.httpPort;
}
async setupTestBankAccount(
bc: BankService,
localName: string,
accountName: string,
password: string,
): Promise<void> {
await bc.createAccount(accountName, password);
const config = Configuration.load(this.configFilename);
config.setString(
`exchange-account-${localName}`,
"wire_response",
`\${TALER_DATA_HOME}/exchange/account-${localName}.json`,
);
config.setString(
`exchange-account-${localName}`,
"payto_uri",
`payto://x-taler-bank/localhost/${accountName}`,
);
config.setString(`exchange-account-${localName}`, "enable_credit", "yes");
config.setString(`exchange-account-${localName}`, "enable_debit", "yes");
config.setString(
`exchange-account-${localName}`,
"wire_gateway_url",
`http://localhost:${bc.port}/taler-wire-gateway/${accountName}/`,
);
config.setString(
`exchange-account-${localName}`,
"wire_gateway_auth_method",
"basic",
);
config.setString(`exchange-account-${localName}`, "username", accountName);
config.setString(`exchange-account-${localName}`, "password", password);
config.write(this.configFilename);
}
exchangeHttpProc: ProcessWrapper | undefined;
exchangeWirewatchProc: ProcessWrapper | undefined;
constructor(
private globalState: GlobalTestState,
private exchangeConfig: ExchangeConfig,
private configFilename: string,
private keyPair: talerCrypto.EddsaKeyPair,
) {}
get name() {
return this.exchangeConfig.name;
}
get baseUrl() {
return `http://localhost:${this.exchangeConfig.httpPort}/`;
}
async start(): Promise<void> {
await exec(`taler-exchange-dbinit -c "${this.configFilename}"`);
await exec(`taler-exchange-wire -c "${this.configFilename}"`);
await exec(`taler-exchange-keyup -c "${this.configFilename}"`);
this.exchangeWirewatchProc = this.globalState.spawnService(
`taler-exchange-wirewatch -c "${this.configFilename}"`,
`exchange-wirewatch-${this.name}`,
);
this.exchangeHttpProc = this.globalState.spawnService(
`taler-exchange-httpd -c "${this.configFilename}"`,
`exchange-httpd-${this.name}`,
);
}
async pingUntilAvailable(): Promise<void> {
const url = `http://localhost:${this.exchangeConfig.httpPort}/keys`;
while (true) {
try {
console.log("pinging exchange");
const resp = await axios.get(url);
console.log(resp.data);
return;
} catch (e) {
console.log("exchange not ready:", e.toString());
await delay(1000);
}
}
}
}
export interface MerchantConfig {
name: string;
currency: string;
httpPort: number;
database: string;
}
export class MerchantService {
proc: ProcessWrapper | undefined;
constructor(
private globalState: GlobalTestState,
private merchantConfig: MerchantConfig,
private configFilename: string,
) {}
async start(): Promise<void> {
await exec(`taler-merchant-dbinit -c "${this.configFilename}"`);
this.proc = this.globalState.spawnService(
`taler-merchant-httpd -c "${this.configFilename}"`,
`merchant-${this.merchantConfig.name}`,
);
}
static async create(
gc: GlobalTestState,
mc: MerchantConfig,
): Promise<MerchantService> {
const config = new Configuration();
config.setString("taler", "currency", mc.currency);
config.setString("merchant", "serve", "tcp");
config.setString("merchant", "port", `${mc.httpPort}`);
config.setString("merchant", "db", "postgres");
config.setString("exchangedb-postgres", "config", mc.database);
const cfgFilename = gc.testDir + `/merchant-${mc.name}.conf`;
config.write(cfgFilename);
return new MerchantService(gc, mc, cfgFilename);
}
addExchange(e: ExchangeServiceInterface): void {
const config = Configuration.load(this.configFilename);
config.setString(
`merchant-exchange-${e.name}`,
"exchange_base_url",
e.baseUrl,
);
config.setString(
`merchant-exchange-${e.name}`,
"currency",
this.merchantConfig.currency,
);
config.setString(`merchant-exchange-${e.name}`, "master_key", e.masterPub);
config.write(this.configFilename);
}
async addInstance(instanceConfig: MerchantInstanceConfig): Promise<void> {
if (!this.proc) {
throw Error("merchant must be running to add instance");
}
console.log("adding instance");
const url = `http://localhost:${this.merchantConfig.httpPort}/private/instances`;
await axios.post(url, {
payto_uris: instanceConfig.paytoUris,
id: instanceConfig.id,
name: instanceConfig.name,
address: instanceConfig.address ?? {},
jurisdiction: instanceConfig.jurisdiction ?? {},
default_max_wire_fee:
instanceConfig.defaultMaxWireFee ??
`${this.merchantConfig.currency}:1.0`,
default_wire_fee_amortization:
instanceConfig.defaultWireFeeAmortization ?? 3,
default_max_deposit_fee:
instanceConfig.defaultMaxDepositFee ??
`${this.merchantConfig.currency}:1.0`,
default_wire_transfer_delay: instanceConfig.defaultWireTransferDelay ?? {
d_ms: "forever",
},
default_pay_delay: instanceConfig.defaultPayDelay ?? { d_ms: "forever" },
});
}
async queryPrivateOrderStatus(instanceName: string, orderId: string) {
let url;
if (instanceName === "default") {
url = `http://localhost:${this.merchantConfig.httpPort}/private/orders/${orderId}`
} else {
url = `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/private/orders/${orderId}`;
}
const resp = await axios.get(url);
return codecForMerchantOrderPrivateStatusResponse().decode(resp.data);
}
async createOrder(
instanceName: string,
req: PostOrderRequest,
): Promise<PostOrderResponse> {
let url;
if (instanceName === "default") {
url = `http://localhost:${this.merchantConfig.httpPort}/private/orders`;
} else {
url = `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/private/orders`;
}
const resp = await axios.post(url, req);
return codecForPostOrderResponse().decode(resp.data);
}
async pingUntilAvailable(): Promise<void> {
const url = `http://localhost:${this.merchantConfig.httpPort}/config`;
while (true) {
try {
console.log("pinging merchant");
const resp = await axios.get(url);
console.log(resp.data);
return;
} catch (e) {
console.log("merchant not ready", e.toString());
await delay(1000);
}
}
}
}
export interface MerchantInstanceConfig {
id: string;
name: string;
paytoUris: string[];
address?: unknown;
jurisdiction?: unknown;
defaultMaxWireFee?: string;
defaultMaxDepositFee?: string;
defaultWireFeeAmortization?: number;
defaultWireTransferDelay?: time.Duration;
defaultPayDelay?: time.Duration;
}
export function runTest(testMain: (gc: GlobalTestState) => Promise<void>) {
const main = async () => {
const gc = new GlobalTestState({
testDir: await makeTempDir(),
});
try {
await testMain(gc);
} finally {
if (process.env["TALER_TEST_KEEP"] !== "1") {
await gc.terminate();
console.log("test logs and config can be found under", gc.testDir);
}
}
};
main().catch((e) => {
console.error("FATAL: test failed with exception");
if (e instanceof Error) {
console.error(e);
} else {
console.error(e);
}
if (process.env["TALER_TEST_KEEP"] !== "1") {
process.exit(1);
}
});
}
function shellWrap(s: string) {
return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'";
}
export class WalletCli {
constructor(private globalTestState: GlobalTestState) {}
async apiRequest(
request: string,
payload: Record<string, unknown>,
): Promise<walletCoreApi.CoreApiResponse> {
const wdb = this.globalTestState.testDir + "/walletdb.json";
const resp = await sh(
`taler-wallet-cli --no-throttle --wallet-db '${wdb}' api '${request}' ${shellWrap(
JSON.stringify(payload),
)}`,
);
console.log(resp);
return JSON.parse(resp) as walletCoreApi.CoreApiResponse;
}
async runUntilDone(): Promise<void> {
const wdb = this.globalTestState.testDir + "/walletdb.json";
await sh(`taler-wallet-cli --no-throttle --wallet-db ${wdb} run-until-done`);
}
async runPending(): Promise<void> {
const wdb = this.globalTestState.testDir + "/walletdb.json";
await sh(`taler-wallet-cli --no-throttle --wallet-db ${wdb} run-pending`);
}
}

View File

@ -0,0 +1,157 @@
/*
This file is part of GNU Taler
(C) 2020 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Helpers to create typical test environments.
*
* @author Florian Dold <dold@taler.net>
*/
/**
* Imports
*/
import {
GlobalTestState,
DbInfo,
ExchangeService,
WalletCli,
MerchantService,
setupDb,
BankService,
} from "./harness";
import { AmountString } from "taler-wallet-core/lib/types/talerTypes";
export interface SimpleTestEnvironment {
commonDb: DbInfo;
bank: BankService;
exchange: ExchangeService;
merchant: MerchantService;
wallet: WalletCli;
}
/**
* Run a test case with a simple TESTKUDOS Taler environment, consisting
* of one exchange, one bank and one merchant.
*/
export async function createSimpleTestkudosEnvironment(
t: GlobalTestState,
): Promise<SimpleTestEnvironment> {
const db = await setupDb(t);
const bank = await BankService.create(t, {
allowRegistrations: true,
currency: "TESTKUDOS",
database: db.connStr,
httpPort: 8082,
suggestedExchange: "http://localhost:8081/",
suggestedExchangePayto: "payto://x-taler-bank/MyExchange",
});
await bank.start();
await bank.pingUntilAvailable();
const exchange = ExchangeService.create(t, {
name: "testexchange-1",
currency: "TESTKUDOS",
httpPort: 8081,
database: db.connStr,
});
await exchange.setupTestBankAccount(bank, "1", "MyExchange", "x");
await exchange.start();
await exchange.pingUntilAvailable();
const merchant = await MerchantService.create(t, {
name: "testmerchant-1",
currency: "TESTKUDOS",
httpPort: 8083,
database: db.connStr,
});
merchant.addExchange(exchange);
await merchant.start();
await merchant.pingUntilAvailable();
await merchant.addInstance({
id: "minst1",
name: "minst1",
paytoUris: ["payto://x-taler-bank/minst1"],
});
await merchant.addInstance({
id: "default",
name: "Default Instance",
paytoUris: [`payto://x-taler-bank/merchant-default`],
});
console.log("setup done!");
const wallet = new WalletCli(t);
return {
commonDb: db,
exchange,
merchant,
wallet,
bank,
};
}
/**
* Withdraw balance.
*/
export async function withdrawViaBank(t: GlobalTestState, p: {
wallet: WalletCli;
bank: BankService;
exchange: ExchangeService;
amount: AmountString;
}): Promise<void> {
const { wallet, bank, exchange, amount } = p;
const user = await bank.createRandomBankUser();
const wop = await bank.createWithdrawalOperation(user, amount);
// Hand it to the wallet
const r1 = await wallet.apiRequest("getWithdrawalDetailsForUri", {
talerWithdrawUri: wop.taler_withdraw_uri,
});
t.assertTrue(r1.type === "response");
await wallet.runPending();
// Confirm it
await bank.confirmWithdrawalOperation(user, wop);
// Withdraw
const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", {
exchangeBaseUrl: exchange.baseUrl,
talerWithdrawUri: wop.taler_withdraw_uri,
});
t.assertTrue(r2.type === "response");
await wallet.runUntilDone();
// Check balance
const balApiResp = await wallet.apiRequest("getBalances", {});
t.assertTrue(balApiResp.type === "response");
}

View File

@ -0,0 +1,217 @@
/*
This file is part of GNU Taler
(C) 2020 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Test harness for various GNU Taler components.
* Also provides a fault-injection proxy.
*
* @author Florian Dold <dold@taler.net>
*/
/**
* Imports
*/
import {
codec,
talerTypes,
time,
} from "taler-wallet-core";
export interface PostOrderRequest {
// The order must at least contain the minimal
// order detail, but can override all
order: Partial<talerTypes.ContractTerms>;
// if set, the backend will then set the refund deadline to the current
// time plus the specified delay.
refund_delay?: time.Duration;
// specifies the payment target preferred by the client. Can be used
// to select among the various (active) wire methods supported by the instance.
payment_target?: string;
// FIXME: some fields are missing
// Should a token for claiming the order be generated?
// False can make sense if the ORDER_ID is sufficiently
// high entropy to prevent adversarial claims (like it is
// if the backend auto-generates one). Default is 'true'.
create_token?: boolean;
}
export type ClaimToken = string;
export interface PostOrderResponse {
order_id: string;
token?: ClaimToken;
}
export const codecForPostOrderResponse = (): codec.Codec<PostOrderResponse> =>
codec
.makeCodecForObject<PostOrderResponse>()
.property("order_id", codec.codecForString)
.property("token", codec.makeCodecOptional(codec.codecForString))
.build("PostOrderResponse");
export const codecForCheckPaymentPaidResponse = (): codec.Codec<
CheckPaymentPaidResponse
> =>
codec
.makeCodecForObject<CheckPaymentPaidResponse>()
.property("order_status", codec.makeCodecForConstString("paid"))
.property("refunded", codec.codecForBoolean)
.property("wired", codec.codecForBoolean)
.property("deposit_total", codec.codecForString)
.property("exchange_ec", codec.codecForNumber)
.property("exchange_hc", codec.codecForNumber)
.property("refund_amount", codec.codecForString)
.property("contract_terms", talerTypes.codecForContractTerms())
// FIXME: specify
.property("wire_details", codec.codecForAny)
.property("wire_reports", codec.codecForAny)
.property("refund_details", codec.codecForAny)
.build("CheckPaymentPaidResponse");
export const codecForCheckPaymentUnpaidResponse = (): codec.Codec<
CheckPaymentUnpaidResponse
> =>
codec
.makeCodecForObject<CheckPaymentUnpaidResponse>()
.property("order_status", codec.makeCodecForConstString("unpaid"))
.property("taler_pay_uri", codec.codecForString)
.property(
"already_paid_order_id",
codec.makeCodecOptional(codec.codecForString),
)
.build("CheckPaymentPaidResponse");
export const codecForMerchantOrderPrivateStatusResponse = (): codec.Codec<
MerchantOrderPrivateStatusResponse
> =>
codec
.makeCodecForUnion<MerchantOrderPrivateStatusResponse>()
.discriminateOn("order_status")
.alternative("paid", codecForCheckPaymentPaidResponse())
.alternative("unpaid", codecForCheckPaymentUnpaidResponse())
.build("MerchantOrderPrivateStatusResponse");
export type MerchantOrderPrivateStatusResponse =
| CheckPaymentPaidResponse
| CheckPaymentUnpaidResponse;
export interface CheckPaymentPaidResponse {
// did the customer pay for this contract
order_status: "paid";
// Was the payment refunded (even partially)
refunded: boolean;
// Did the exchange wire us the funds
wired: boolean;
// Total amount the exchange deposited into our bank account
// for this contract, excluding fees.
deposit_total: talerTypes.AmountString;
// Numeric error code indicating errors the exchange
// encountered tracking the wire transfer for this purchase (before
// we even got to specific coin issues).
// 0 if there were no issues.
exchange_ec: number;
// HTTP status code returned by the exchange when we asked for
// information to track the wire transfer for this purchase.
// 0 if there were no issues.
exchange_hc: number;
// Total amount that was refunded, 0 if refunded is false.
refund_amount: talerTypes.AmountString;
// Contract terms
contract_terms: talerTypes.ContractTerms;
// Ihe wire transfer status from the exchange for this order if available, otherwise empty array
wire_details: TransactionWireTransfer[];
// Reports about trouble obtaining wire transfer details, empty array if no trouble were encountered.
wire_reports: TransactionWireReport[];
// The refund details for this order. One entry per
// refunded coin; empty array if there are no refunds.
refund_details: RefundDetails[];
}
export interface CheckPaymentUnpaidResponse {
order_status: "unpaid";
// URI that the wallet must process to complete the payment.
taler_pay_uri: string;
// Alternative order ID which was paid for already in the same session.
// Only given if the same product was purchased before in the same session.
already_paid_order_id?: string;
// We do we NOT return the contract terms here because they may not
// exist in case the wallet did not yet claim them.
}
export interface RefundDetails {
// Reason given for the refund
reason: string;
// when was the refund approved
timestamp: time.Timestamp;
// Total amount that was refunded (minus a refund fee).
amount: talerTypes.AmountString;
}
export interface TransactionWireTransfer {
// Responsible exchange
exchange_url: string;
// 32-byte wire transfer identifier
wtid: string;
// execution time of the wire transfer
execution_time: time.Timestamp;
// Total amount that has been wire transfered
// to the merchant
amount: talerTypes.AmountString;
// Was this transfer confirmed by the merchant via the
// POST /transfers API, or is it merely claimed by the exchange?
confirmed: boolean;
}
export interface TransactionWireReport {
// Numerical error code
code: number;
// Human-readable error description
hint: string;
// Numerical error code from the exchange.
exchange_ec: number;
// HTTP status code received from the exchange.
exchange_hc: number;
// Public key of the coin for which we got the exchange error.
coin_pub: talerTypes.CoinPublicKeyString;
}

View File

@ -0,0 +1,194 @@
/*
This file is part of GNU Taler
(C) 2020 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Sample fault injection test.
*/
/**
* Imports.
*/
import {
runTest,
GlobalTestState,
MerchantService,
ExchangeService,
setupDb,
BankService,
WalletCli,
} from "./harness";
import { FaultInjectedExchangeService, FaultInjectionRequestContext, FaultInjectionResponseContext } from "./faultInjection";
import { CoreApiResponse } from "taler-wallet-core/lib/walletCoreApiHandler";
/**
* Run test for basic, bank-integrated withdrawal.
*/
runTest(async (t: GlobalTestState) => {
// Set up test environment
const db = await setupDb(t);
const bank = await BankService.create(t, {
allowRegistrations: true,
currency: "TESTKUDOS",
database: db.connStr,
httpPort: 8082,
suggestedExchange: "http://localhost:8091/",
suggestedExchangePayto: "payto://x-taler-bank/MyExchange",
});
await bank.start();
await bank.pingUntilAvailable();
const exchange = ExchangeService.create(t, {
name: "testexchange-1",
currency: "TESTKUDOS",
httpPort: 8081,
database: db.connStr,
});
await exchange.setupTestBankAccount(bank, "1", "MyExchange", "x");
await exchange.start();
await exchange.pingUntilAvailable();
const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
// Print all requests to the exchange
faultyExchange.faultProxy.addFault({
modifyRequest(ctx: FaultInjectionRequestContext) {
console.log("got request", ctx);
},
modifyResponse(ctx: FaultInjectionResponseContext) {
console.log("got response", ctx);
}
});
const merchant = await MerchantService.create(t, {
name: "testmerchant-1",
currency: "TESTKUDOS",
httpPort: 8083,
database: db.connStr,
});
merchant.addExchange(faultyExchange);
await merchant.start();
await merchant.pingUntilAvailable();
await merchant.addInstance({
id: "default",
name: "Default Instance",
paytoUris: [`payto://x-taler-bank/merchant-default`],
});
console.log("setup done!");
const wallet = new WalletCli(t);
// Create withdrawal operation
const user = await bank.createRandomBankUser();
const wop = await bank.createWithdrawalOperation(user, "TESTKUDOS:20");
// Hand it to the wallet
const r1 = await wallet.apiRequest("getWithdrawalDetailsForUri", {
talerWithdrawUri: wop.taler_withdraw_uri,
});
t.assertTrue(r1.type === "response");
await wallet.runPending();
// Confirm it
await bank.confirmWithdrawalOperation(user, wop);
// Withdraw
const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", {
exchangeBaseUrl: faultyExchange.baseUrl,
talerWithdrawUri: wop.taler_withdraw_uri,
});
t.assertTrue(r2.type === "response");
await wallet.runUntilDone();
// Check balance
const balApiResp = await wallet.apiRequest("getBalances", {});
t.assertTrue(balApiResp.type === "response");
// Set up order.
const orderResp = await merchant.createOrder("default", {
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
fulfillment_url: "taler://fulfillment-success/thx",
},
});
let orderStatus = await merchant.queryPrivateOrderStatus(
"default",
orderResp.order_id,
);
t.assertTrue(orderStatus.order_status === "unpaid");
// Make wallet pay for the order
let apiResp: CoreApiResponse;
apiResp = await wallet.apiRequest("preparePay", {
talerPayUri: orderStatus.taler_pay_uri,
});
t.assertTrue(apiResp.type === "response");
const proposalId = (apiResp.result as any).proposalId;
await wallet.runPending();
// Drop 10 responses from the exchange.
let faultCount = 0;
faultyExchange.faultProxy.addFault({
modifyResponse(ctx: FaultInjectionResponseContext) {
if (faultCount < 10) {
faultCount++;
ctx.dropResponse = true;
}
}
});
// confirmPay won't work, as the exchange is unreachable
apiResp = await wallet.apiRequest("confirmPay", {
// FIXME: should be validated, don't cast!
proposalId: proposalId,
});
t.assertTrue(apiResp.type === "error");
await wallet.runUntilDone();
// Check if payment was successful.
orderStatus = await merchant.queryPrivateOrderStatus(
"default",
orderResp.order_id,
);
t.assertTrue(orderStatus.order_status === "paid");
});

View File

@ -0,0 +1,80 @@
/*
This file is part of GNU Taler
(C) 2020 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Imports.
*/
import { runTest, GlobalTestState } from "./harness";
import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
/**
* Run test for basic, bank-integrated withdrawal.
*/
runTest(async (t: GlobalTestState) => {
// Set up test environment
const {
wallet,
bank,
exchange,
merchant,
} = await createSimpleTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
// Set up order.
const orderResp = await merchant.createOrder("default", {
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
fulfillment_url: "taler://fulfillment-success/thx",
},
});
let orderStatus = await merchant.queryPrivateOrderStatus(
"default",
orderResp.order_id,
);
t.assertTrue(orderStatus.order_status === "unpaid")
// Make wallet pay for the order
const r1 = await wallet.apiRequest("preparePay", {
talerPayUri: orderStatus.taler_pay_uri,
});
t.assertTrue(r1.type === "response");
const r2 = await wallet.apiRequest("confirmPay", {
// FIXME: should be validated, don't cast!
proposalId: (r1.result as any).proposalId,
});
t.assertTrue(r2.type === "response");
// Check if payment was successful.
orderStatus = await merchant.queryPrivateOrderStatus(
"default",
orderResp.order_id,
);
t.assertTrue(orderStatus.order_status === "paid");
await t.terminate();
});

View File

@ -0,0 +1,68 @@
/*
This file is part of GNU Taler
(C) 2020 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Imports.
*/
import { runTest, GlobalTestState } from "./harness";
import { createSimpleTestkudosEnvironment } from "./helpers";
import { walletTypes } from "taler-wallet-core";
/**
* Run test for basic, bank-integrated withdrawal.
*/
runTest(async (t: GlobalTestState) => {
// Set up test environment
const { wallet, bank, exchange } = await createSimpleTestkudosEnvironment(t);
// Create a withdrawal operation
const user = await bank.createRandomBankUser();
const wop = await bank.createWithdrawalOperation(user, "TESTKUDOS:10");
// Hand it to the wallet
const r1 = await wallet.apiRequest("getWithdrawalDetailsForUri", {
talerWithdrawUri: wop.taler_withdraw_uri,
});
t.assertTrue(r1.type === "response");
await wallet.runPending();
// Confirm it
await bank.confirmWithdrawalOperation(user, wop);
// Withdraw
const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", {
exchangeBaseUrl: exchange.baseUrl,
talerWithdrawUri: wop.taler_withdraw_uri,
});
t.assertTrue(r2.type === "response");
await wallet.runUntilDone();
// Check balance
const balApiResp = await wallet.apiRequest("getBalances", {});
t.assertTrue(balApiResp.type === "response");
const balResp = walletTypes.codecForBalancesResponse().decode(balApiResp.result);
t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available)
await t.terminate();
});

View File

@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Simple test runner for the wallet integration tests.
#
# Usage: $0 TESTGLOB
#
# The TESTGLOB can be used to select which test cases to execute
set -eu
if [ "$#" -ne 1 ]; then
echo "Usage: $0 TESTGLOB"
exit 1
fi
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
cd $DIR
./node_modules/.bin/tsc
export ESM_OPTIONS='{"sourceMap": true}'
shopt -s extglob
num_exec=0
num_fail=0
num_succ=0
# Glob tests
for file in lib/$1?(.js); do
case "$file" in
*.js)
echo "executing test $file"
ret=0
node -r source-map-support/register -r esm $file || ret=$?
num_exec=$((num_exec+1))
case $ret in
0)
num_succ=$((num_succ+1))
;;
*)
num_fail=$((num_fail+1))
;;
esac
;;
*)
continue
;;
esac
done
echo "-----------------------------------"
echo "Tests finished"
echo "$num_succ/$num_exec tests succeeded"
echo "-----------------------------------"
if [[ $num_fail = 0 ]]; then
exit 0
else
exit 1
fi

View File

@ -0,0 +1,32 @@
{
"compileOnSave": true,
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": false,
"target": "ES6",
"module": "ESNext",
"moduleResolution": "node",
"sourceMap": true,
"lib": ["es6"],
"types": ["node"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
"strictPropertyInitialization": false,
"outDir": "lib",
"noImplicitAny": true,
"noImplicitThis": true,
"incremental": true,
"esModuleInterop": true,
"importHelpers": true,
"rootDir": "./src",
"typeRoots": ["./node_modules/@types"]
},
"references": [
{
"path": "../idb-bridge/"
}
],
"include": ["src/**/*"]
}

View File

@ -113,6 +113,7 @@ export class AndroidHttpLib implements httpLib.HttpRequestLibrary {
requestUrl: "", requestUrl: "",
headers, headers,
status: msg.status, status: msg.status,
requestMethod: "FIXME",
json: async () => JSON.parse(msg.responseText), json: async () => JSON.parse(msg.responseText),
text: async () => msg.responseText, text: async () => msg.responseText,
}; };

View File

@ -4,4 +4,4 @@ try {
} catch (e) { } catch (e) {
// Do nothing. // Do nothing.
} }
require('../dist/taler-wallet-cli.js') require('../dist/taler-wallet-cli.js').walletCli.run();

View File

@ -34,6 +34,12 @@ import {
NodeHttpLib, NodeHttpLib,
} from "taler-wallet-core"; } from "taler-wallet-core";
import * as clk from "./clk"; import * as clk from "./clk";
import { NodeThreadCryptoWorkerFactory } from "taler-wallet-core/lib/crypto/workers/nodeThreadWorker";
import { CryptoApi } from "taler-wallet-core/lib/crypto/workers/cryptoApi";
// This module also serves as the entry point for the crypto
// thread worker, and thus must expose these two handlers.
export { handleWorkerError, handleWorkerMessage } from "taler-wallet-core";
const logger = new Logger("taler-wallet-cli.ts"); const logger = new Logger("taler-wallet-cli.ts");
@ -109,7 +115,7 @@ function printVersion(): void {
process.exit(0); process.exit(0);
} }
const walletCli = clk export const walletCli = clk
.program("wallet", { .program("wallet", {
help: "Command line interface for the GNU Taler wallet.", help: "Command line interface for the GNU Taler wallet.",
}) })
@ -637,4 +643,9 @@ testCli.subcommand("vectors", "vectors").action(async (args) => {
testvectors.printTestVectors(); testvectors.printTestVectors();
}); });
walletCli.run(); testCli.subcommand("cryptoworker", "cryptoworker").action(async (args) => {
const workerFactory = new NodeThreadCryptoWorkerFactory();
const cryptoApi = new CryptoApi(workerFactory);
const res = await cryptoApi.hashString("foo");
console.log(res);
});

View File

@ -34,25 +34,27 @@
"@typescript-eslint/eslint-plugin": "^3.6.1", "@typescript-eslint/eslint-plugin": "^3.6.1",
"@typescript-eslint/parser": "^3.6.1", "@typescript-eslint/parser": "^3.6.1",
"ava": "^3.10.1", "ava": "^3.10.1",
"dts-bundle-generator": "^5.3.0",
"eslint": "^7.4.0", "eslint": "^7.4.0",
"eslint-config-airbnb-typescript": "^8.0.2", "eslint-config-airbnb-typescript": "^8.0.2",
"eslint-plugin-import": "^2.22.0", "eslint-plugin-import": "^2.22.0",
"eslint-plugin-jsx-a11y": "^6.3.1", "eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-react": "^7.20.3", "eslint-plugin-react": "^7.20.3",
"eslint-plugin-react-hooks": "^4.0.8", "eslint-plugin-react-hooks": "^4.0.8",
"esm": "^3.2.25",
"jed": "^1.1.1", "jed": "^1.1.1",
"moment": "^2.27.0", "moment": "^2.27.0",
"nyc": "^15.1.0", "nyc": "^15.1.0",
"po2json": "^0.4.5", "po2json": "^0.4.5",
"pogen": "workspace:*", "pogen": "workspace:*",
"prettier": "^2.0.5", "prettier": "^2.0.5",
"rimraf": "^3.0.2",
"rollup": "^2.23.0",
"rollup-plugin-sourcemaps": "^0.6.2",
"source-map-resolve": "^0.6.0", "source-map-resolve": "^0.6.0",
"structured-clone": "^0.2.2", "structured-clone": "^0.2.2",
"typedoc": "^0.17.8", "typedoc": "^0.17.8",
"typescript": "^3.9.7", "typescript": "^3.9.7"
"rollup": "^2.23.0",
"esm": "^3.2.25",
"rimraf": "^3.0.2"
}, },
"dependencies": { "dependencies": {
"@types/node": "^14.0.27", "@types/node": "^14.0.27",
@ -63,7 +65,9 @@
"tslib": "^2.0.0" "tslib": "^2.0.0"
}, },
"ava": { "ava": {
"require": ["esm"], "require": [
"esm"
],
"files": [ "files": [
"src/**/*-test.*" "src/**/*-test.*"
], ],

View File

@ -4,13 +4,14 @@ import nodeResolve from "@rollup/plugin-node-resolve";
import json from "@rollup/plugin-json"; import json from "@rollup/plugin-json";
import builtins from "builtin-modules"; import builtins from "builtin-modules";
import pkg from "./package.json"; import pkg from "./package.json";
import sourcemaps from 'rollup-plugin-sourcemaps';
export default { export default {
input: "lib/index.js", input: "lib/index.js",
output: { output: {
file: pkg.main, file: pkg.main,
format: "cjs", format: "cjs",
sourcemap: false, sourcemap: true,
}, },
external: builtins, external: builtins,
plugins: [ plugins: [
@ -18,11 +19,13 @@ export default {
preferBuiltins: true, preferBuiltins: true,
}), }),
sourcemaps(),
commonjs({ commonjs({
include: [/node_modules/, /dist/], include: [/node_modules/, /dist/],
extensions: [".js"], extensions: [".js"],
ignoreGlobal: false, ignoreGlobal: false,
sourceMap: false, sourceMap: true,
}), }),
json(), json(),

View File

@ -1,17 +1,17 @@
/* /*
This file is part of TALER This file is part of GNU Taler
(C) 2016 GNUnet e.V. (C) 2016 GNUnet e.V.
TALER is free software; you can redistribute it and/or modify it under the GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version. Foundation; either version 3, or (at your option) any later version.
TALER is distributed in the hope that it will be useful, but WITHOUT ANY GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details. 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 You should have received a copy of the GNU General Public License along with
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
/** /**
@ -46,6 +46,7 @@ import {
import * as timer from "../../util/timer"; import * as timer from "../../util/timer";
import { Logger } from "../../util/logging"; import { Logger } from "../../util/logging";
import { walletCoreApi } from "../..";
const logger = new Logger("cryptoApi.ts"); const logger = new Logger("cryptoApi.ts");
@ -182,7 +183,7 @@ export class CryptoApi {
}; };
this.resetWorkerTimeout(ws); this.resetWorkerTimeout(ws);
work.startTime = timer.performanceNow(); work.startTime = timer.performanceNow();
setTimeout(() => worker.postMessage(msg), 0); timer.after(0, () => worker.postMessage(msg));
} }
resetWorkerTimeout(ws: WorkerState): void { resetWorkerTimeout(ws: WorkerState): void {
@ -198,6 +199,7 @@ export class CryptoApi {
} }
}; };
ws.terminationTimerHandle = timer.after(15 * 1000, destroy); ws.terminationTimerHandle = timer.after(15 * 1000, destroy);
//ws.terminationTimerHandle.unref();
} }
handleWorkerError(ws: WorkerState, e: any): void { handleWorkerError(ws: WorkerState, e: any): void {

View File

@ -21,6 +21,9 @@ import { CryptoWorkerFactory } from "./cryptoApi";
import { CryptoWorker } from "./cryptoWorker"; import { CryptoWorker } from "./cryptoWorker";
import os from "os"; import os from "os";
import { CryptoImplementation } from "./cryptoImplementation"; import { CryptoImplementation } from "./cryptoImplementation";
import { Logger } from "../../util/logging";
const logger = new Logger("nodeThreadWorker.ts");
const f = __filename; const f = __filename;
@ -37,16 +40,22 @@ const workerCode = `
try { try {
tw = require("${f}"); tw = require("${f}");
} catch (e) { } catch (e) {
console.log("could not load from ${f}"); console.warn("could not load from ${f}");
} }
if (!tw) { if (!tw) {
try { try {
tw = require("taler-wallet-android"); tw = require("taler-wallet-android");
} catch (e) { } catch (e) {
console.log("could not load taler-wallet-android either"); console.warn("could not load taler-wallet-android either");
throw e; throw e;
} }
} }
if (typeof tw.handleWorkerMessage !== "function") {
throw Error("module loaded for crypto worker lacks handleWorkerMessage");
}
if (typeof tw.handleWorkerError !== "function") {
throw Error("module loaded for crypto worker lacks handleWorkerError");
}
parentPort.on("message", tw.handleWorkerMessage); parentPort.on("message", tw.handleWorkerMessage);
parentPort.on("error", tw.handleWorkerError); parentPort.on("error", tw.handleWorkerError);
`; `;
@ -138,6 +147,9 @@ class NodeThreadCryptoWorker implements CryptoWorker {
constructor() { constructor() {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const worker_threads = require("worker_threads"); const worker_threads = require("worker_threads");
logger.trace("starting node crypto worker");
this.nodeWorker = new worker_threads.Worker(workerCode, { eval: true }); this.nodeWorker = new worker_threads.Worker(workerCode, { eval: true });
this.nodeWorker.on("error", (err: Error) => { this.nodeWorker.on("error", (err: Error) => {
console.error("error in node worker:", err); console.error("error in node worker:", err);
@ -145,6 +157,9 @@ class NodeThreadCryptoWorker implements CryptoWorker {
this.onerror(err); this.onerror(err);
} }
}); });
this.nodeWorker.on("exit", (err) => {
logger.trace(`worker exited with code ${err}`);
});
this.nodeWorker.on("message", (v: any) => { this.nodeWorker.on("message", (v: any) => {
if (this.onmessage) { if (this.onmessage) {
this.onmessage(v); this.onmessage(v);

View File

@ -45,7 +45,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
} }
private async req( private async req(
method: "post" | "get", method: "POST" | "GET",
url: string, url: string,
body: any, body: any,
opt?: HttpRequestOptions, opt?: HttpRequestOptions,
@ -72,6 +72,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
{ {
httpStatusCode: resp.status, httpStatusCode: resp.status,
requestUrl: url, requestUrl: url,
requestMethod: method,
}, },
), ),
); );
@ -88,6 +89,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
{ {
httpStatusCode: resp.status, httpStatusCode: resp.status,
requestUrl: url, requestUrl: url,
requestMethod: method,
}, },
), ),
); );
@ -100,6 +102,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
{ {
httpStatusCode: resp.status, httpStatusCode: resp.status,
requestUrl: url, requestUrl: url,
requestMethod: method,
}, },
), ),
); );
@ -112,6 +115,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
} }
return { return {
requestUrl: url, requestUrl: url,
requestMethod: method,
headers, headers,
status: resp.status, status: resp.status,
text: async () => resp.data, text: async () => resp.data,
@ -120,7 +124,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
} }
async get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { async get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
return this.req("get", url, undefined, opt); return this.req("GET", url, undefined, opt);
} }
async postJson( async postJson(
@ -128,6 +132,6 @@ export class NodeHttpLib implements HttpRequestLibrary {
body: any, body: any,
opt?: HttpRequestOptions, opt?: HttpRequestOptions,
): Promise<HttpResponse> { ): Promise<HttpResponse> {
return this.req("post", url, body, opt); return this.req("POST", url, body, opt);
} }
} }

View File

@ -73,3 +73,10 @@ export * as i18n from "./i18n";
export * as nodeThreadWorker from "./crypto/workers/nodeThreadWorker"; export * as nodeThreadWorker from "./crypto/workers/nodeThreadWorker";
export * as walletNotifications from "./types/notifications"; export * as walletNotifications from "./types/notifications";
export { Configuration } from "./util/talerconfig";
export {
handleWorkerMessage,
handleWorkerError,
} from "./crypto/workers/nodeThreadWorker";

View File

@ -112,6 +112,8 @@ async function updateExchangeWithKeys(
return; return;
} }
logger.info("updating exchange /keys info");
const keysUrl = new URL("keys", baseUrl); const keysUrl = new URL("keys", baseUrl);
keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
@ -121,6 +123,8 @@ async function updateExchangeWithKeys(
codecForExchangeKeysJson(), codecForExchangeKeysJson(),
); );
logger.info("received /keys response");
if (exchangeKeysJson.denoms.length === 0) { if (exchangeKeysJson.denoms.length === 0) {
const opErr = makeErrorDetails( const opErr = makeErrorDetails(
TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT, TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
@ -152,12 +156,16 @@ async function updateExchangeWithKeys(
const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value)
.currency; .currency;
logger.trace("processing denominations");
const newDenominations = await Promise.all( const newDenominations = await Promise.all(
exchangeKeysJson.denoms.map((d) => exchangeKeysJson.denoms.map((d) =>
denominationRecordFromKeys(ws, baseUrl, d), denominationRecordFromKeys(ws, baseUrl, d),
), ),
); );
logger.trace("done with processing denominations");
const lastUpdateTimestamp = getTimestampNow(); const lastUpdateTimestamp = getTimestampNow();
const recoupGroupId: string | undefined = undefined; const recoupGroupId: string | undefined = undefined;
@ -241,6 +249,8 @@ async function updateExchangeWithKeys(
console.log("error while recouping coins:", e); console.log("error while recouping coins:", e);
}); });
} }
logger.trace("done updating exchange /keys");
} }
async function updateExchangeFinalize( async function updateExchangeFinalize(

View File

@ -781,7 +781,7 @@ export async function submitPay(
} }
const sessionId = purchase.lastSessionId; const sessionId = purchase.lastSessionId;
console.log("paying with session ID", sessionId); logger.trace("paying with session ID", sessionId);
const payUrl = new URL( const payUrl = new URL(
`orders/${purchase.contractData.orderId}/pay`, `orders/${purchase.contractData.orderId}/pay`,

View File

@ -712,7 +712,9 @@ export async function getWithdrawalDetailsForUri(
ws: InternalWalletState, ws: InternalWalletState,
talerWithdrawUri: string, talerWithdrawUri: string,
): Promise<WithdrawUriInfoResponse> { ): Promise<WithdrawUriInfoResponse> {
logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
const info = await getBankWithdrawalInfo(ws, talerWithdrawUri); const info = await getBankWithdrawalInfo(ws, talerWithdrawUri);
logger.trace(`got bank info`);
if (info.suggestedExchange) { if (info.suggestedExchange) {
// FIXME: right now the exchange gets permanently added, // FIXME: right now the exchange gets permanently added,
// we might want to only temporarily add it. // we might want to only temporarily add it.

View File

@ -40,8 +40,11 @@ import {
codecForString, codecForString,
makeCodecOptional, makeCodecOptional,
Codec, Codec,
makeCodecForList,
codecForBoolean,
} from "../util/codec"; } from "../util/codec";
import { AmountString } from "./talerTypes"; import { AmountString } from "./talerTypes";
import { codec } from "..";
/** /**
* Response for the create reserve request to the wallet. * Response for the create reserve request to the wallet.
@ -164,6 +167,20 @@ export interface BalancesResponse {
balances: Balance[]; balances: Balance[];
} }
export const codecForBalance = (): Codec<Balance> =>
makeCodecForObject<Balance>()
.property("available", codecForString)
.property("hasPendingTransactions", codecForBoolean)
.property("pendingIncoming", codecForString)
.property("pendingOutgoing", codecForString)
.property("requiresUserInput", codecForBoolean)
.build("Balance");
export const codecForBalancesResponse = (): Codec<BalancesResponse> =>
makeCodecForObject<BalancesResponse>()
.property("balances", makeCodecForList(codecForBalance()))
.build("BalancesResponse");
/** /**
* For terseness. * For terseness.
*/ */

View File

@ -34,6 +34,7 @@ const logger = new Logger("http.ts");
*/ */
export interface HttpResponse { export interface HttpResponse {
requestUrl: string; requestUrl: string;
requestMethod: string;
status: number; status: number;
headers: Headers; headers: Headers;
json(): Promise<any>; json(): Promise<any>;
@ -118,6 +119,8 @@ export async function readSuccessResponseJsonOrErrorCode<T>(
"Error response did not contain error code", "Error response did not contain error code",
{ {
requestUrl: httpResponse.requestUrl, requestUrl: httpResponse.requestUrl,
requestMethod: httpResponse.requestMethod,
httpStatusCode: httpResponse.status,
}, },
), ),
); );
@ -188,7 +191,9 @@ export async function readSuccessResponseTextOrErrorCode<T>(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
"Error response did not contain error code", "Error response did not contain error code",
{ {
httpStatusCode: httpResponse.status,
requestUrl: httpResponse.requestUrl, requestUrl: httpResponse.requestUrl,
requestMethod: httpResponse.requestMethod,
}, },
), ),
); );
@ -217,7 +222,9 @@ export async function checkSuccessResponseOrThrow(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
"Error response did not contain error code", "Error response did not contain error code",
{ {
httpStatusCode: httpResponse.status,
requestUrl: httpResponse.requestUrl, requestUrl: httpResponse.requestUrl,
requestMethod: httpResponse.requestMethod,
}, },
), ),
); );

View File

@ -0,0 +1,124 @@
/*
This file is part of GNU Taler
(C) 2020 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Imports
*/
import test from "ava";
import { pathsub, Configuration } from "./talerconfig";
test("pathsub", (t) => {
t.assert("foo" === pathsub("foo", () => undefined));
t.assert("fo${bla}o" === pathsub("fo${bla}o", () => undefined));
const d: Record<string, string> = {
w: "world",
f: "foo",
"1foo": "x",
"foo_bar": "quux",
};
t.is(
pathsub("hello ${w}!", (v) => d[v]),
"hello world!",
);
t.is(
pathsub("hello ${w} ${w}!", (v) => d[v]),
"hello world world!",
);
t.is(
pathsub("hello ${x:-blabla}!", (v) => d[v]),
"hello blabla!",
);
// No braces
t.is(
pathsub("hello $w!", (v) => d[v]),
"hello world!",
);
t.is(
pathsub("hello $foo!", (v) => d[v]),
"hello $foo!",
);
t.is(
pathsub("hello $1foo!", (v) => d[v]),
"hello $1foo!",
);
t.is(
pathsub("hello $$ world!", (v) => d[v]),
"hello $$ world!",
);
t.is(
pathsub("hello $$ world!", (v) => d[v]),
"hello $$ world!",
);
t.is(
pathsub("hello $foo_bar!", (v) => d[v]),
"hello quux!",
);
// Recursive lookup in default
t.is(
pathsub("hello ${x:-${w}}!", (v) => d[v]),
"hello world!",
);
// No variables in variable name part
t.is(
pathsub("hello ${${w}:-x}!", (v) => d[v]),
"hello ${${w}:-x}!",
);
// Missing closing brace
t.is(
pathsub("hello ${w!", (v) => d[v]),
"hello ${w!",
);
});
test("path expansion", (t) => {
const config = new Configuration();
config.setString("paths", "taler_home", "foo/bar");
config.setString(
"paths",
"taler_data_home",
"$TALER_HOME/.local/share/taler/",
);
config.setString(
"exchange",
"master_priv_file",
"${TALER_DATA_HOME}/exchange/offline-keys/master.priv",
);
t.is(
config.getPath("exchange", "MaStER_priv_file").required(),
"foo/bar/.local/share/taler//exchange/offline-keys/master.priv",
);
});
test("recursive path resolution", (t) => {
console.log("recursive test");
const config = new Configuration();
config.setString("paths", "a", "x${b}");
config.setString("paths", "b", "y${a}");
config.setString("foo", "x", "z${a}");
t.throws(() => {
config.getPath("foo", "a").required();
});
});

View File

@ -25,6 +25,8 @@
*/ */
import { AmountJson } from "./amounts"; import { AmountJson } from "./amounts";
import * as Amounts from "./amounts"; import * as Amounts from "./amounts";
import fs from "fs";
import { acceptExchangeTermsOfService } from "../operations/exchanges";
export class ConfigError extends Error { export class ConfigError extends Error {
constructor(message: string) { constructor(message: string) {
@ -56,6 +58,89 @@ export class ConfigValue<T> {
} }
} }
/**
* Shell-style path substitution.
*
* Supported patterns:
* "$x" (look up "x")
* "${x}" (look up "x")
* "${x:-y}" (look up "x", fall back to expanded y)
*/
export function pathsub(
x: string,
lookup: (s: string, depth: number) => string | undefined,
depth = 0,
): string {
if (depth >= 10) {
throw Error("recursion in path substitution");
}
let s = x;
let l = 0;
while (l < s.length) {
if (s[l] === "$") {
if (s[l + 1] === "{") {
let depth = 1;
const start = l;
let p = start + 2;
let insideNamePart = true;
let hasDefault = false;
for (; p < s.length; p++) {
if (s[p] == "}") {
insideNamePart = false;
depth--;
} else if (s[p] === "$" && s[p + 1] === "{") {
insideNamePart = false;
depth++;
}
if (insideNamePart && s[p] === ":" && s[p + 1] === "-") {
hasDefault = true;
}
if (depth == 0) {
break;
}
}
if (depth == 0) {
const inner = s.slice(start + 2, p);
let varname: string;
let defaultValue: string | undefined;
if (hasDefault) {
[varname, defaultValue] = inner.split(":-", 2);
} else {
varname = inner;
defaultValue = undefined;
}
const r = lookup(inner, depth + 1);
if (r !== undefined) {
s = s.substr(0, start) + r + s.substr(p + 1);
l = start + r.length;
continue;
} else if (defaultValue !== undefined) {
const resolvedDefault = pathsub(defaultValue, lookup, depth + 1);
s = s.substr(0, start) + resolvedDefault + s.substr(p + 1);
l = start + resolvedDefault.length;
continue;
}
}
l = p;
continue;
} else {
const m = /^[a-zA-Z-_][a-zA-Z0-9-_]*/.exec(s.substring(l + 1));
if (m && m[0]) {
const r = lookup(m[0], depth + 1);
if (r !== undefined) {
s = s.substr(0, l) + r + s.substr(l + 1 + m[0].length);
l = l + r.length;
continue;
}
}
}
}
l++;
}
return s;
}
export class Configuration { export class Configuration {
private sectionMap: SectionMap = {}; private sectionMap: SectionMap = {};
@ -69,7 +154,6 @@ export class Configuration {
const lines = s.split("\n"); const lines = s.split("\n");
for (const line of lines) { for (const line of lines) {
console.log("parsing line", JSON.stringify(line));
if (reEmptyLine.test(line)) { if (reEmptyLine.test(line)) {
continue; continue;
} }
@ -79,15 +163,15 @@ export class Configuration {
const secMatch = line.match(reSection); const secMatch = line.match(reSection);
if (secMatch) { if (secMatch) {
currentSection = secMatch[1]; currentSection = secMatch[1];
console.log("setting section to", currentSection);
continue; continue;
} }
if (currentSection === undefined) { if (currentSection === undefined) {
throw Error("invalid configuration, expected section header"); throw Error("invalid configuration, expected section header");
} }
currentSection = currentSection.toUpperCase();
const paramMatch = line.match(reParam); const paramMatch = line.match(reParam);
if (paramMatch) { if (paramMatch) {
const optName = paramMatch[1]; const optName = paramMatch[1].toUpperCase();
let val = paramMatch[2]; let val = paramMatch[2];
if (val.startsWith('"') && val.endsWith('"')) { if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, val.length - 1); val = val.slice(1, val.length - 1);
@ -102,13 +186,44 @@ export class Configuration {
"invalid configuration, expected section header or option assignment", "invalid configuration, expected section header or option assignment",
); );
} }
}
console.log("parsed config", JSON.stringify(this.sectionMap, undefined, 2)); setString(section: string, option: string, value: string): void {
const secNorm = section.toUpperCase();
const sec = this.sectionMap[secNorm] ?? (this.sectionMap[secNorm] = {});
sec[option.toUpperCase()] = value;
} }
getString(section: string, option: string): ConfigValue<string> { getString(section: string, option: string): ConfigValue<string> {
const val = (this.sectionMap[section] ?? {})[option]; const secNorm = section.toUpperCase();
return new ConfigValue(section, option, val, (x) => x); const optNorm = option.toUpperCase();
const val = (this.sectionMap[section] ?? {})[optNorm];
return new ConfigValue(secNorm, optNorm, val, (x) => x);
}
getPath(section: string, option: string): ConfigValue<string> {
const secNorm = section.toUpperCase();
const optNorm = option.toUpperCase();
const val = (this.sectionMap[secNorm] ?? {})[optNorm];
return new ConfigValue(secNorm, optNorm, val, (x) =>
pathsub(x, (v, d) => this.lookupVariable(v, d + 1)),
);
}
lookupVariable(x: string, depth: number = 0): string | undefined {
console.log("looking up", x);
// We loop up options in PATHS in upper case, as option names
// are case insensitive
const val = (this.sectionMap["PATHS"] ?? {})[x.toUpperCase()];
if (val !== undefined) {
return pathsub(val, (v, d) => this.lookupVariable(v, d), depth);
}
// Environment variables can be case sensitive, respect that.
const envVal = process.env[x];
if (envVal !== undefined) {
return envVal;
}
return;
} }
getAmount(section: string, option: string): ConfigValue<AmountJson> { getAmount(section: string, option: string): ConfigValue<AmountJson> {
@ -117,4 +232,28 @@ export class Configuration {
Amounts.parseOrThrow(x), Amounts.parseOrThrow(x),
); );
} }
static load(filename: string): Configuration {
const s = fs.readFileSync(filename, "utf-8");
const cfg = new Configuration();
cfg.loadFromString(s);
return cfg;
}
write(filename: string): void {
let s = "";
for (const sectionName of Object.keys(this.sectionMap)) {
s += `[${sectionName}]\n`;
for (const optionName of Object.keys(
this.sectionMap[sectionName] ?? {},
)) {
const val = this.sectionMap[sectionName][optionName];
if (val !== undefined) {
s += `${optionName} = ${val}\n`;
}
}
s += "\n";
}
fs.writeFileSync(filename, s);
}
} }

View File

@ -34,6 +34,12 @@ const logger = new Logger("timer.ts");
*/ */
export interface TimerHandle { export interface TimerHandle {
clear(): void; clear(): void;
/**
* Make sure the event loop exits when the timer is the
* only event left. Has no effect in the browser.
*/
unref(): void;
} }
class IntervalHandle { class IntervalHandle {
@ -42,6 +48,16 @@ class IntervalHandle {
clear(): void { clear(): void {
clearInterval(this.h); clearInterval(this.h);
} }
/**
* Make sure the event loop exits when the timer is the
* only event left. Has no effect in the browser.
*/
unref(): void {
if (typeof this.h === "object") {
this.h.unref();
}
}
} }
class TimeoutHandle { class TimeoutHandle {
@ -50,6 +66,16 @@ class TimeoutHandle {
clear(): void { clear(): void {
clearTimeout(this.h); clearTimeout(this.h);
} }
/**
* Make sure the event loop exits when the timer is the
* only event left. Has no effect in the browser.
*/
unref(): void {
if (typeof this.h === "object") {
this.h.unref();
}
}
} }
/** /**
@ -92,6 +118,10 @@ const nullTimerHandle = {
// do nothing // do nothing
return; return;
}, },
unref() {
// do nothing
return;
}
}; };
/** /**
@ -141,6 +171,9 @@ export class TimerGroup {
h.clear(); h.clear();
delete tm[myId]; delete tm[myId];
}, },
unref() {
h.unref();
}
}; };
} }
@ -160,6 +193,9 @@ export class TimerGroup {
h.clear(); h.clear();
delete tm[myId]; delete tm[myId];
}, },
unref() {
h.unref();
}
}; };
} }
} }

View File

@ -102,6 +102,7 @@ export class BrowserHttpLib implements httpLib.HttpRequestLibrary {
requestUrl: url, requestUrl: url,
status: myRequest.status, status: myRequest.status,
headers: headerMap, headers: headerMap,
requestMethod: method,
json: makeJson, json: makeJson,
text: async () => myRequest.responseText, text: async () => myRequest.responseText,
}; };
@ -112,7 +113,7 @@ export class BrowserHttpLib implements httpLib.HttpRequestLibrary {
} }
get(url: string, opt?: httpLib.HttpRequestOptions): Promise<httpLib.HttpResponse> { get(url: string, opt?: httpLib.HttpRequestOptions): Promise<httpLib.HttpResponse> {
return this.req("get", url, undefined, opt); return this.req("GET", url, undefined, opt);
} }
postJson( postJson(
@ -120,7 +121,7 @@ export class BrowserHttpLib implements httpLib.HttpRequestLibrary {
body: unknown, body: unknown,
opt?: httpLib.HttpRequestOptions, opt?: httpLib.HttpRequestOptions,
): Promise<httpLib.HttpResponse> { ): Promise<httpLib.HttpResponse> {
return this.req("post", url, JSON.stringify(body), opt); return this.req("POST", url, JSON.stringify(body), opt);
} }
stop(): void { stop(): void {

View File

@ -29,6 +29,28 @@ importers:
specifiers: specifiers:
'@types/node': ^11.12.0 '@types/node': ^11.12.0
typescript: ^3.3.4000 typescript: ^3.3.4000
packages/taler-integrationtests:
dependencies:
axios: 0.19.2
taler-wallet-core: 'link:../taler-wallet-core'
tslib: 2.0.0
typescript: 3.9.7
devDependencies:
'@ava/typescript': 1.1.1
ava: 3.11.1
esm: 3.2.25
source-map-support: 0.5.19
ts-node: 8.10.2_typescript@3.9.7
specifiers:
'@ava/typescript': ^1.1.1
ava: ^3.11.1
axios: ^0.19.2
esm: ^3.2.25
source-map-support: ^0.5.19
taler-wallet-core: 'workspace:*'
ts-node: ^8.10.2
tslib: ^2.0.0
typescript: ^3.9.7
packages/taler-wallet-android: packages/taler-wallet-android:
dependencies: dependencies:
taler-wallet-core: 'link:../taler-wallet-core' taler-wallet-core: 'link:../taler-wallet-core'
@ -100,6 +122,7 @@ importers:
'@typescript-eslint/eslint-plugin': 3.7.1_98f5354ad0bbc327ab4925c12674a6b1 '@typescript-eslint/eslint-plugin': 3.7.1_98f5354ad0bbc327ab4925c12674a6b1
'@typescript-eslint/parser': 3.7.1_eslint@7.6.0+typescript@3.9.7 '@typescript-eslint/parser': 3.7.1_eslint@7.6.0+typescript@3.9.7
ava: 3.11.0 ava: 3.11.0
dts-bundle-generator: 5.3.0
eslint: 7.6.0 eslint: 7.6.0
eslint-config-airbnb-typescript: 8.0.2_de36c6f68d63a4142de06a31bab9d790 eslint-config-airbnb-typescript: 8.0.2_de36c6f68d63a4142de06a31bab9d790
eslint-plugin-import: 2.22.0_eslint@7.6.0 eslint-plugin-import: 2.22.0_eslint@7.6.0
@ -115,6 +138,7 @@ importers:
prettier: 2.0.5 prettier: 2.0.5
rimraf: 3.0.2 rimraf: 3.0.2
rollup: 2.23.0 rollup: 2.23.0
rollup-plugin-sourcemaps: 0.6.2_1bb4f16ce5b550396581a296af208cfa
source-map-resolve: 0.6.0 source-map-resolve: 0.6.0
structured-clone: 0.2.2 structured-clone: 0.2.2
typedoc: 0.17.8_typescript@3.9.7 typedoc: 0.17.8_typescript@3.9.7
@ -127,6 +151,7 @@ importers:
ava: ^3.10.1 ava: ^3.10.1
axios: ^0.19.2 axios: ^0.19.2
big-integer: ^1.6.48 big-integer: ^1.6.48
dts-bundle-generator: ^5.3.0
eslint: ^7.4.0 eslint: ^7.4.0
eslint-config-airbnb-typescript: ^8.0.2 eslint-config-airbnb-typescript: ^8.0.2
eslint-plugin-import: ^2.22.0 eslint-plugin-import: ^2.22.0
@ -143,6 +168,7 @@ importers:
prettier: ^2.0.5 prettier: ^2.0.5
rimraf: ^3.0.2 rimraf: ^3.0.2
rollup: ^2.23.0 rollup: ^2.23.0
rollup-plugin-sourcemaps: ^0.6.2
source-map-resolve: ^0.6.0 source-map-resolve: ^0.6.0
source-map-support: ^0.5.19 source-map-support: ^0.5.19
structured-clone: ^0.2.2 structured-clone: ^0.2.2
@ -861,6 +887,10 @@ packages:
dev: true dev: true
resolution: resolution:
integrity: sha1-+cjBN1fMHde8N5rHeyxipcKGjEA= integrity: sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=
/arg/4.1.3:
dev: true
resolution:
integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
/argparse/1.0.10: /argparse/1.0.10:
dependencies: dependencies:
sprintf-js: 1.0.3 sprintf-js: 1.0.3
@ -1032,6 +1062,69 @@ packages:
hasBin: true hasBin: true
resolution: resolution:
integrity: sha512-y5U8BGeSRjs/OypsC4CJxr+L1KtLKU5kUyHr5hcghXn7HNr2f4LE/4gvl0Q5lNkLX1obdRW1oODphNdU/glwmA== integrity: sha512-y5U8BGeSRjs/OypsC4CJxr+L1KtLKU5kUyHr5hcghXn7HNr2f4LE/4gvl0Q5lNkLX1obdRW1oODphNdU/glwmA==
/ava/3.11.1:
dependencies:
'@concordance/react': 2.0.0
acorn: 7.3.1
acorn-walk: 7.2.0
ansi-styles: 4.2.1
arrgv: 1.0.2
arrify: 2.0.1
callsites: 3.1.0
chalk: 4.1.0
chokidar: 3.4.1
chunkd: 2.0.1
ci-info: 2.0.0
ci-parallel-vars: 1.0.1
clean-yaml-object: 0.1.0
cli-cursor: 3.1.0
cli-truncate: 2.1.0
code-excerpt: 3.0.0
common-path-prefix: 3.0.0
concordance: 5.0.0
convert-source-map: 1.7.0
currently-unhandled: 0.4.1
debug: 4.1.1
del: 5.1.0
emittery: 0.7.1
equal-length: 1.0.1
figures: 3.2.0
globby: 11.0.1
ignore-by-default: 2.0.0
import-local: 3.0.2
indent-string: 4.0.0
is-error: 2.2.2
is-plain-object: 4.1.1
is-promise: 4.0.0
lodash: 4.17.19
matcher: 3.0.0
md5-hex: 3.0.1
mem: 6.1.0
ms: 2.1.2
ora: 4.0.5
p-map: 4.0.0
picomatch: 2.2.2
pkg-conf: 3.1.0
plur: 4.0.0
pretty-ms: 7.0.0
read-pkg: 5.2.0
resolve-cwd: 3.0.0
slash: 3.0.0
source-map-support: 0.5.19
stack-utils: 2.0.2
strip-ansi: 6.0.0
supertap: 1.0.0
temp-dir: 2.0.0
trim-off-newlines: 1.0.1
update-notifier: 4.1.0
write-file-atomic: 3.0.3
yargs: 15.4.1
dev: true
engines:
node: '>=10.18.0 <11 || >=12.14.0 <12.17.0 || >=12.17.0 <13 || >=14.0.0'
hasBin: true
resolution:
integrity: sha512-yGPD0msa5Qronw7GHDNlLaB7oU5zryYtXeuvny40YV6TMskSghqK7Ky3NisM/sr+aqI3DY7sfmORx8dIWQgMoQ==
/axe-core/3.5.5: /axe-core/3.5.5:
dev: true dev: true
engines: engines:
@ -1541,6 +1634,12 @@ packages:
node: '>=8' node: '>=8'
resolution: resolution:
integrity: sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA== integrity: sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA==
/diff/4.0.2:
dev: true
engines:
node: '>=0.3.1'
resolution:
integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
/dir-glob/3.0.1: /dir-glob/3.0.1:
dependencies: dependencies:
path-type: 4.0.0 path-type: 4.0.0
@ -1617,6 +1716,16 @@ packages:
node: '>=8' node: '>=8'
resolution: resolution:
integrity: sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A== integrity: sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==
/dts-bundle-generator/5.3.0:
dependencies:
typescript: 3.9.7
yargs: 15.4.1
dev: true
engines:
node: '>=12.0.0'
hasBin: true
resolution:
integrity: sha512-PevcqtUQDsVs1FoXNEEvBgXWP2pNXT/booL+ufNcKSynEP8l01ebI9MgamECljThi+MHyjxYEbwGx+95TvigMQ==
/duplexer3/0.1.4: /duplexer3/0.1.4:
dev: true dev: true
resolution: resolution:
@ -3070,6 +3179,10 @@ packages:
node: '>=8' node: '>=8'
resolution: resolution:
integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
/make-error/1.3.6:
dev: true
resolution:
integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
/map-age-cleaner/0.1.3: /map-age-cleaner/0.1.3:
dependencies: dependencies:
p-defer: 1.0.0 p-defer: 1.0.0
@ -3353,14 +3466,14 @@ packages:
dev: true dev: true
resolution: resolution:
integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E= integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
/onetime/5.1.0: /onetime/5.1.1:
dependencies: dependencies:
mimic-fn: 2.1.0 mimic-fn: 2.1.0
dev: true dev: true
engines: engines:
node: '>=6' node: '>=6'
resolution: resolution:
integrity: sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q== integrity: sha512-ZpZpjcJeugQfWsfyQlshVoowIIQ1qBGSVll4rfDq6JJVO//fesjoX808hXWfBjY+ROZgpKDI5TRSRBSoJiZ8eg==
/optionator/0.9.1: /optionator/0.9.1:
dependencies: dependencies:
deep-is: 0.1.3 deep-is: 0.1.3
@ -3944,7 +4057,7 @@ packages:
integrity: sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec= integrity: sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=
/restore-cursor/3.1.0: /restore-cursor/3.1.0:
dependencies: dependencies:
onetime: 5.1.0 onetime: 5.1.1
signal-exit: 3.0.3 signal-exit: 3.0.3
dev: true dev: true
engines: engines:
@ -4449,6 +4562,22 @@ packages:
node: '>=0.10.0' node: '>=0.10.0'
resolution: resolution:
integrity: sha1-n5up2e+odkw4dpi8v+sshI8RrbM= integrity: sha1-n5up2e+odkw4dpi8v+sshI8RrbM=
/ts-node/8.10.2_typescript@3.9.7:
dependencies:
arg: 4.1.3
diff: 4.0.2
make-error: 1.3.6
source-map-support: 0.5.19
typescript: 3.9.7
yn: 3.1.1
dev: true
engines:
node: '>=6.0.0'
hasBin: true
peerDependencies:
typescript: '>=2.7'
resolution:
integrity: sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==
/tsconfig-paths/3.9.0: /tsconfig-paths/3.9.0:
dependencies: dependencies:
'@types/json5': 0.0.29 '@types/json5': 0.0.29
@ -4539,7 +4668,6 @@ packages:
resolution: resolution:
integrity: sha512-/OyrHCJ8jtzu+QZ+771YaxQ9s4g5Z3XsQE3Ma7q+BL392xxBn4UMvvCdVnqKC2T/dz03/VXSLVKOP3lHmDdc/w== integrity: sha512-/OyrHCJ8jtzu+QZ+771YaxQ9s4g5Z3XsQE3Ma7q+BL392xxBn4UMvvCdVnqKC2T/dz03/VXSLVKOP3lHmDdc/w==
/typescript/3.9.7: /typescript/3.9.7:
dev: true
engines: engines:
node: '>=4.2.0' node: '>=4.2.0'
hasBin: true hasBin: true
@ -4736,3 +4864,9 @@ packages:
node: '>=8' node: '>=8'
resolution: resolution:
integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
/yn/3.1.1:
dev: true
engines:
node: '>=6'
resolution:
integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==