/* This file is part of GNU Taler (C) 2019 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 */ /** * Imports. */ import { addPaytoQueryParams, Amounts, TalerCorebankApiClient, Configuration, decodeCrock, j2s, Logger, MerchantApiClient, rsaBlind, setGlobalLogLevelFromString, RegisterAccountRequest, HttpStatusCode, MerchantInstanceConfig, Duration, generateIban, } from "@gnu-taler/taler-util"; import { clk } from "@gnu-taler/taler-util/clk"; import { HttpResponse, createPlatformHttpLib, } from "@gnu-taler/taler-util/http"; import { CryptoDispatcher, downloadExchangeInfo, SynchronousCryptoWorkerFactoryPlain, topupReserveWithDemobank, } from "@gnu-taler/taler-wallet-core"; import { deepStrictEqual } from "assert"; import fs from "fs"; import os from "os"; import path from "path"; import { runBench1 } from "./bench1.js"; import { runBench2 } from "./bench2.js"; import { runBench3 } from "./bench3.js"; import { runEnvFull } from "./env-full.js"; import { runEnv1 } from "./env1.js"; import { GlobalTestState, delayMs, runTestWithState, } from "./harness/harness.js"; import { getTestInfo, runTests } from "./integrationtests/testrunner.js"; import { lintExchangeDeployment } from "./lint.js"; const logger = new Logger("taler-harness:index.ts"); process.on("unhandledRejection", (error: any) => { logger.error("unhandledRejection", error.message); logger.error("stack", error.stack); process.exit(2); }); declare const __VERSION__: string; function printVersion(): void { console.log(__VERSION__); process.exit(0); } export const testingCli = clk .program("testing", { help: "Command line interface for the GNU Taler test/deployment harness.", }) .maybeOption("log", ["-L", "--log"], clk.STRING, { help: "configure log level (NONE, ..., TRACE)", onPresentHandler: (x) => { setGlobalLogLevelFromString(x); }, }) .flag("version", ["-v", "--version"], { onPresentHandler: printVersion, }) .flag("verbose", ["-V", "--verbose"], { help: "Enable verbose output.", }); const advancedCli = testingCli.subcommand("advancedArgs", "advanced", { help: "Subcommands for advanced operations (only use if you know what you're doing!).", }); advancedCli .subcommand("decode", "decode", { help: "Decode base32-crockford.", }) .action((args) => { const enc = fs.readFileSync(0, "utf8"); console.log(decodeCrock(enc.trim())); }); advancedCli .subcommand("bench1", "bench1", { help: "Run the 'bench1' benchmark", }) .requiredOption("configJson", ["--config-json"], clk.STRING) .action(async (args) => { let config: any; try { config = JSON.parse(args.bench1.configJson); } catch (e) { console.log("Could not parse config JSON"); } await runBench1(config); }); advancedCli .subcommand("bench2", "bench2", { help: "Run the 'bench2' benchmark", }) .requiredOption("configJson", ["--config-json"], clk.STRING) .action(async (args) => { let config: any; try { config = JSON.parse(args.bench2.configJson); } catch (e) { console.log("Could not parse config JSON"); } await runBench2(config); }); advancedCli .subcommand("bench3", "bench3", { help: "Run the 'bench3' benchmark", }) .requiredOption("configJson", ["--config-json"], clk.STRING) .action(async (args) => { let config: any; try { config = JSON.parse(args.bench3.configJson); } catch (e) { console.log("Could not parse config JSON"); } await runBench3(config); }); advancedCli .subcommand("envFull", "env-full", { help: "Run a test environment for bench1", }) .action(async (args) => { const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-env-full-")); const testState = new GlobalTestState({ testDir, }); await runTestWithState(testState, runEnvFull, "env-full", true); }); advancedCli .subcommand("env1", "env1", { help: "Run a test environment for bench1", }) .action(async (args) => { const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-env1-")); const testState = new GlobalTestState({ testDir, }); await runTestWithState(testState, runEnv1, "env1", true); }); const sandcastleCli = testingCli.subcommand("sandcastleArgs", "sandcastle", { help: "Subcommands for handling GNU Taler sandcastle deployments.", }); const configCli = testingCli.subcommand("configArgs", "config", { help: "Subcommands for handling the Taler configuration.", }); configCli.subcommand("show", "show").action(async (args) => { const config = Configuration.load(); const cfgStr = config.stringify({ diagnostics: true, }); console.log(cfgStr); }); configCli .subcommand("get", "get") .requiredArgument("section", clk.STRING) .requiredArgument("option", clk.STRING) .flag("file", ["-f"]) .action(async (args) => { const config = Configuration.load(); let res; if (args.get.file) { res = config.getPath(args.get.section, args.get.option); } else { res = config.getString(args.get.section, args.get.option); } if (res.isDefined()) { console.log(res.required()); } else { console.warn("not found"); process.exit(1); } }); const deploymentCli = testingCli.subcommand("deploymentArgs", "deployment", { help: "Subcommands for handling GNU Taler deployments.", }); deploymentCli .subcommand("tipTopup", "tip-topup") .requiredOption("merchantBaseUrl", ["--merchant-url"], clk.STRING) .requiredOption("exchangeBaseUrl", ["--exchange-url"], clk.STRING) .requiredOption("merchantApikey", ["--merchant-apikey"], clk.STRING) .requiredOption("bankAccessUrl", ["--bank-access-url"], clk.STRING) .requiredOption("bankAccount", ["--bank-account"], clk.STRING) .requiredOption("bankPassword", ["--bank-password"], clk.STRING) .requiredOption("wireMethod", ["--wire-method"], clk.STRING) .requiredOption("amount", ["--amount"], clk.STRING) .action(async (args) => { const amount = args.tipTopup.amount; const merchantClient = new MerchantApiClient( args.tipTopup.merchantBaseUrl, { method: "token", token: args.tipTopup.merchantApikey, }, ); const res = await merchantClient.getPrivateInstanceInfo(); console.log(res); const tipReserveResp = await merchantClient.createTippingReserve({ exchange_url: args.tipTopup.exchangeBaseUrl, initial_balance: amount, wire_method: args.tipTopup.wireMethod, }); console.log(tipReserveResp); const bankAccessApiClient = new TalerCorebankApiClient( args.tipTopup.bankAccessUrl, { auth: { username: args.tipTopup.bankAccount, password: args.tipTopup.bankPassword, }, }, ); const paytoUri = addPaytoQueryParams(tipReserveResp.accounts[0].payto_uri, { message: `tip-reserve ${tipReserveResp.reserve_pub}`, }); console.log("payto URI:", paytoUri); const transactions = await bankAccessApiClient.getTransactions( args.tipTopup.bankAccount, ); console.log("transactions:", j2s(transactions)); await bankAccessApiClient.createTransaction(args.tipTopup.bankAccount, { amount, paytoUri, }); }); deploymentCli .subcommand("tipCleanup", "tip-cleanup") .requiredOption("merchantBaseUrl", ["--merchant-url"], clk.STRING) .requiredOption("merchantApikey", ["--merchant-apikey"], clk.STRING) .flag("dryRun", ["--dry-run"]) .action(async (args) => { const merchantClient = new MerchantApiClient( args.tipCleanup.merchantBaseUrl, { method: "token", token: args.tipCleanup.merchantApikey, }, ); const res = await merchantClient.getPrivateInstanceInfo(); console.log(res); const tipRes = await merchantClient.getPrivateTipReserves(); console.log(tipRes); for (const reserve of tipRes.reserves) { if (Amounts.isZero(reserve.exchange_initial_amount)) { if (args.tipCleanup.dryRun) { logger.info(`dry run, would purge reserve ${reserve}`); } else { await merchantClient.deleteTippingReserve({ reservePub: reserve.reserve_pub, purge: true, }); } } } // FIXME: Now delete reserves that are not filled yet }); deploymentCli .subcommand("testTalerdotnetDemo", "test-demodottalerdotnet") .action(async (args) => { const http = createPlatformHttpLib(); const cryptiDisp = new CryptoDispatcher( new SynchronousCryptoWorkerFactoryPlain(), ); const cryptoApi = cryptiDisp.cryptoApi; const reserveKeyPair = await cryptoApi.createEddsaKeypair({}); const exchangeBaseUrl = "https://exchange.demo.taler.net/"; const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http); await topupReserveWithDemobank({ amount: "KUDOS:10", corebankApiBaseUrl: "https://bank.demo.taler.net/", exchangeInfo, http, reservePub: reserveKeyPair.pub, }); let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl); reserveUrl.searchParams.set("timeout_ms", "30000"); console.log("requesting", reserveUrl.href); const longpollReq = http.fetch(reserveUrl.href, { method: "GET", }); const reserveStatusResp = await longpollReq; console.log("reserve status", reserveStatusResp.status); }); deploymentCli .subcommand("testDemoTestdotdalerdotnet", "test-testdottalerdotnet") .action(async (args) => { const http = createPlatformHttpLib(); const cryptiDisp = new CryptoDispatcher( new SynchronousCryptoWorkerFactoryPlain(), ); const cryptoApi = cryptiDisp.cryptoApi; const reserveKeyPair = await cryptoApi.createEddsaKeypair({}); const exchangeBaseUrl = "https://exchange.test.taler.net/"; const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http); await topupReserveWithDemobank({ amount: "TESTKUDOS:10", corebankApiBaseUrl: "https://bank.test.taler.net/", exchangeInfo, http, reservePub: reserveKeyPair.pub, }); let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl); reserveUrl.searchParams.set("timeout_ms", "30000"); console.log("requesting", reserveUrl.href); const longpollReq = http.fetch(reserveUrl.href, { method: "GET", }); const reserveStatusResp = await longpollReq; console.log("reserve status", reserveStatusResp.status); }); deploymentCli .subcommand("testLocalhostDemo", "test-demo-localhost") .action(async (args) => { // Run checks against the "env-full" demo deployment on localhost const http = createPlatformHttpLib(); const cryptiDisp = new CryptoDispatcher( new SynchronousCryptoWorkerFactoryPlain(), ); const cryptoApi = cryptiDisp.cryptoApi; const reserveKeyPair = await cryptoApi.createEddsaKeypair({}); const exchangeBaseUrl = "http://localhost:8081/"; const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http); await topupReserveWithDemobank({ amount: "TESTKUDOS:10", corebankApiBaseUrl: "http://localhost:8082/taler-bank-access/", exchangeInfo, http, reservePub: reserveKeyPair.pub, }); let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl); reserveUrl.searchParams.set("timeout_ms", "30000"); console.log("requesting", reserveUrl.href); const longpollReq = http.fetch(reserveUrl.href, { method: "GET", }); const reserveStatusResp = await longpollReq; console.log("reserve status", reserveStatusResp.status); }); deploymentCli .subcommand("tipStatus", "tip-status") .requiredOption("merchantBaseUrl", ["--merchant-url"], clk.STRING) .requiredOption("merchantApikey", ["--merchant-apikey"], clk.STRING) .action(async (args) => { const merchantClient = new MerchantApiClient( args.tipStatus.merchantBaseUrl, { method: "token", token: args.tipStatus.merchantApikey, }, ); const res = await merchantClient.getPrivateInstanceInfo(); const tipRes = await merchantClient.getPrivateTipReserves(); console.log(j2s(tipRes)); }); deploymentCli .subcommand("lintExchange", "lint-exchange", { help: "Run checks on the exchange deployment.", }) .flag("cont", ["--continue"], { help: "Continue after errors if possible", }) .flag("debug", ["--debug"], { help: "Output extra debug info", }) .action(async (args) => { await lintExchangeDeployment( args.lintExchange.debug, args.lintExchange.cont, ); }); deploymentCli .subcommand("waitService", "wait-taler-service", { help: "Wait for the config endpoint of a Taler-style service to be available", }) .requiredArgument("serviceName", clk.STRING) .requiredArgument("serviceConfigUrl", clk.STRING) .action(async (args) => { const serviceName = args.waitService.serviceName; const serviceUrl = args.waitService.serviceConfigUrl; console.log( `Waiting for service ${serviceName} to be ready at ${serviceUrl}`, ); const httpLib = createPlatformHttpLib(); while (1) { console.log(`Fetching ${serviceUrl}`); let resp: HttpResponse; try { resp = await httpLib.fetch(serviceUrl); } catch (e) { console.log( `Got network error for service ${serviceName} at ${serviceUrl}`, ); await delayMs(1000); continue; } if (resp.status != 200) { console.log( `Got unexpected status ${resp.status} for service at ${serviceUrl}`, ); await delayMs(1000); continue; } let respJson: any; try { respJson = await resp.json(); } catch (e) { console.log( `Got json error for service ${serviceName} at ${serviceUrl}`, ); await delayMs(1000); continue; } const recServiceName = respJson.name; console.log(`Got name ${recServiceName}`); if (recServiceName != serviceName) { console.log(`A different service is still running at ${serviceUrl}`); await delayMs(1000); continue; } console.log(`service ${serviceName} at ${serviceUrl} is now available`); return; } }); deploymentCli .subcommand("waitEndpoint", "wait-service", { help: "Wait for an endpoint to return an HTTP 200 Ok status with JSON body", }) .requiredArgument("serviceEndpoint", clk.STRING) .action(async (args) => { const serviceUrl = args.waitEndpoint.serviceEndpoint; console.log(`Waiting for endpoint ${serviceUrl} to be ready`); const httpLib = createPlatformHttpLib(); while (1) { console.log(`Fetching ${serviceUrl}`); let resp: HttpResponse; try { resp = await httpLib.fetch(serviceUrl); } catch (e) { console.log(`Got network error for service at ${serviceUrl}`); await delayMs(1000); continue; } if (resp.status != 200) { console.log( `Got unexpected status ${resp.status} for service at ${serviceUrl}`, ); await delayMs(1000); continue; } let respJson: any; try { respJson = await resp.json(); } catch (e) { console.log(`Got json error for service at ${serviceUrl}`); await delayMs(1000); continue; } return; } }); deploymentCli .subcommand("genIban", "gen-iban", { help: "Generate a random IBAN.", }) .requiredArgument("countryCode", clk.STRING) .requiredArgument("length", clk.INT) .action(async (args) => { console.log(generateIban(args.genIban.countryCode, args.genIban.length)); }); deploymentCli .subcommand("provisionMerchantInstance", "provision-merchant-instance", { help: "Provision a merchant backend instance.", }) .requiredArgument("merchantApiBaseUrl", clk.STRING) .requiredOption("managementToken", ["--management-token"], clk.STRING) .requiredOption("instanceToken", ["--instance-token"], clk.STRING) .requiredOption("name", ["--name"], clk.STRING) .requiredOption("id", ["--id"], clk.STRING) .requiredOption("payto", ["--payto"], clk.STRING) .action(async (args) => { const httpLib = createPlatformHttpLib(); const baseUrl = args.provisionMerchantInstance.merchantApiBaseUrl; const managementToken = args.provisionMerchantInstance.managementToken; const instanceToken = args.provisionMerchantInstance.instanceToken; const instanceId = args.provisionMerchantInstance.id; const body: MerchantInstanceConfig = { address: {}, auth: { method: "token", token: args.provisionMerchantInstance.instanceToken, }, default_pay_delay: Duration.toTalerProtocolDuration( Duration.fromSpec({ hours: 1 }), ), default_wire_transfer_delay: { d_us: 1 }, id: instanceId, jurisdiction: {}, name: args.provisionMerchantInstance.name, use_stefan: true, }; const url = new URL("management/instances", baseUrl); const createResp = await httpLib.fetch(url.href, { method: "POST", body, headers: { Authorization: `Bearer ${managementToken}`, }, }); if (createResp.status >= 200 && createResp.status <= 299) { logger.info(`instance ${instanceId} created successfully`); } else if (createResp.status === HttpStatusCode.Conflict) { logger.info(`instance ${instanceId} already exists`); } else { logger.error( `unable to create instance ${instanceId}, HTTP status ${createResp.status}`, ); } const accountsUrl = new URL( `instances/${instanceId}/private/accounts`, baseUrl, ); const accountBody = { payto_uri: args.provisionMerchantInstance.payto, }; const createAccountResp = await httpLib.fetch(accountsUrl.href, { method: "POST", body: accountBody, headers: { Authorization: `Bearer ${instanceToken}`, }, }); if (createAccountResp.status != 200) { console.error( `unable to configure bank account for instance ${instanceId}, status ${createAccountResp.status}`, ); const resp = await createAccountResp.json(); console.error(j2s(resp)); process.exit(2); } logger.info(`successfully configured bank account for ${instanceId}`); }); deploymentCli .subcommand("provisionBankAccount", "provision-bank-account", { help: "Provision a corebank account.", }) .requiredArgument("corebankApiBaseUrl", clk.STRING) .flag("exchange", ["--exchange"]) .flag("public", ["--public"]) .requiredOption("login", ["--login"], clk.STRING) .requiredOption("name", ["--name"], clk.STRING) .requiredOption("password", ["--password"], clk.STRING) .maybeOption("internalPayto", ["--payto"], clk.STRING) .action(async (args) => { const httpLib = createPlatformHttpLib(); const corebankApiBaseUrl = args.provisionBankAccount.corebankApiBaseUrl; const url = new URL("accounts", corebankApiBaseUrl); const accountLogin = args.provisionBankAccount.login; const body: RegisterAccountRequest = { name: args.provisionBankAccount.name, password: args.provisionBankAccount.password, username: accountLogin, is_public: !!args.provisionBankAccount.public, is_taler_exchange: !!args.provisionBankAccount.exchange, internal_payto_uri: args.provisionBankAccount.internalPayto, }; const resp = await httpLib.fetch(url.href, { method: "POST", body, }); if (resp.status >= 200 && resp.status <= 299) { logger.info(`account ${accountLogin} successfully provisioned`); return; } if (resp.status === HttpStatusCode.Conflict) { logger.info(`account ${accountLogin} already provisioned`); return; } logger.error( `unable to provision bank account, HTTP response status ${resp.status}`, ); process.exit(2); }); deploymentCli .subcommand("coincfg", "gen-coin-config", { help: "Generate a coin/denomination configuration for the exchange.", }) .requiredOption("minAmount", ["--min-amount"], clk.STRING, { help: "Smallest denomination", }) .requiredOption("maxAmount", ["--max-amount"], clk.STRING, { help: "Largest denomination", }) .action(async (args) => { let out = ""; const stamp = Math.floor(new Date().getTime() / 1000); const min = Amounts.parseOrThrow(args.coincfg.minAmount); const max = Amounts.parseOrThrow(args.coincfg.maxAmount); if (min.currency != max.currency) { console.error("currency mismatch"); process.exit(1); } const currency = min.currency; let x = min; let n = 1; out += "# Coin configuration for the exchange.\n"; out += '# Should be placed in "/etc/taler/conf.d/exchange-coins.conf".\n'; out += "\n"; while (Amounts.cmp(x, max) < 0) { out += `[COIN-${currency}-n${n}-t${stamp}]\n`; out += `VALUE = ${Amounts.stringify(x)}\n`; out += `DURATION_WITHDRAW = 7 days\n`; out += `DURATION_SPEND = 2 years\n`; out += `DURATION_LEGAL = 6 years\n`; out += `FEE_WITHDRAW = ${currency}:0\n`; out += `FEE_DEPOSIT = ${Amounts.stringify(min)}\n`; out += `FEE_REFRESH = ${currency}:0\n`; out += `FEE_REFUND = ${currency}:0\n`; out += `RSA_KEYSIZE = 2048\n`; out += `CIPHER = RSA\n`; out += "\n"; x = Amounts.add(x, x).amount; n++; } console.log(out); }); const deploymentConfigCli = deploymentCli.subcommand("configArgs", "config", { help: "Subcommands the Taler configuration.", }); deploymentConfigCli .subcommand("show", "show") .flag("diagnostics", ["-d", "--diagnostics"]) .maybeArgument("cfgfile", clk.STRING, {}) .action(async (args) => { const cfg = Configuration.load(args.show.cfgfile); console.log( cfg.stringify({ diagnostics: args.show.diagnostics, }), ); }); testingCli.subcommand("logtest", "logtest").action(async (args) => { logger.trace("This is a trace message."); logger.info("This is an info message."); logger.warn("This is an warning message."); logger.error("This is an error message."); }); testingCli .subcommand("listIntegrationtests", "list-integrationtests") .action(async (args) => { for (const t of getTestInfo()) { let s = t.name; if (t.suites.length > 0) { s += ` (suites: ${t.suites.join(",")})`; } if (t.experimental) { s += ` [experimental]`; } console.log(s); } }); testingCli .subcommand("runIntegrationtests", "run-integrationtests") .maybeArgument("pattern", clk.STRING, { help: "Glob pattern to select which tests to run", }) .maybeOption("suites", ["--suites"], clk.STRING, { help: "Only run selected suites (comma-separated list)", }) .flag("dryRun", ["--dry"], { help: "Only print tests that will be selected to run.", }) .flag("experimental", ["--experimental"], { help: "Include tests marked as experimental", }) .flag("failFast", ["--fail-fast"], { help: "Exit after the first error", }) .flag("waitOnFail", ["--wait-on-fail"], { help: "Exit after the first error", }) .flag("quiet", ["--quiet"], { help: "Produce less output.", }) .flag("noTimeout", ["--no-timeout"], { help: "Do not time out tests.", }) .action(async (args) => { await runTests({ includePattern: args.runIntegrationtests.pattern, failFast: args.runIntegrationtests.failFast, waitOnFail: args.runIntegrationtests.waitOnFail, suiteSpec: args.runIntegrationtests.suites, dryRun: args.runIntegrationtests.dryRun, verbosity: args.runIntegrationtests.quiet ? 0 : 1, includeExperimental: args.runIntegrationtests.experimental ?? false, noTimeout: args.runIntegrationtests.noTimeout, }); }); async function read(stream: NodeJS.ReadStream) { const chunks = []; for await (const chunk of stream) chunks.push(chunk); return Buffer.concat(chunks).toString("utf8"); } testingCli.subcommand("tvgcheck", "tvgcheck").action(async (args) => { const data = await read(process.stdin); const lines = data.match(/[^\r\n]+/g); if (!lines) { throw Error("can't split lines"); } const vals: Record = {}; let inBlindSigningSection = false; for (const line of lines) { if (line === "blind signing:") { inBlindSigningSection = true; continue; } if (line[0] !== " ") { inBlindSigningSection = false; continue; } if (inBlindSigningSection) { const m = line.match(/ (\w+) (\w+)/); if (!m) { console.log("bad format"); process.exit(2); } vals[m[1]] = m[2]; } } console.log(vals); const req = (k: string) => { if (!vals[k]) { throw Error(`no value for ${k}`); } return decodeCrock(vals[k]); }; const myBm = rsaBlind( req("message_hash"), req("blinding_key_secret"), req("rsa_public_key"), ); deepStrictEqual(req("blinded_message"), myBm); console.log("check passed!"); }); export function main() { testingCli.run(); }