diff options
Diffstat (limited to 'packages/taler-harness/src/index.ts')
-rw-r--r-- | packages/taler-harness/src/index.ts | 1288 |
1 files changed, 1288 insertions, 0 deletions
diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts new file mode 100644 index 000000000..0f282e123 --- /dev/null +++ b/packages/taler-harness/src/index.ts @@ -0,0 +1,1288 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + AccessToken, + AmountString, + Amounts, + BalancesResponse, + Configuration, + Duration, + HttpStatusCode, + Logger, + PaytoString, + TalerAuthenticationHttpClient, + TalerBankConversionHttpClient, + TalerCoreBankHttpClient, + TalerErrorCode, + TalerMerchantInstanceHttpClient, + TalerMerchantManagementHttpClient, + TransactionsResponse, + createAccessToken, + decodeCrock, + encodeCrock, + generateIban, + j2s, + randomBytes, + rsaBlind, + setGlobalLogLevelFromString, + setPrintHttpRequestAsCurl, + stringifyPayTemplateUri, +} from "@gnu-taler/taler-util"; +import { clk } from "@gnu-taler/taler-util/clk"; +import { + HttpResponse, + createPlatformHttpLib, +} from "@gnu-taler/taler-util/http"; +import { + CryptoDispatcher, + SynchronousCryptoWorkerFactoryPlain, + WalletApiOperation, +} from "@gnu-taler/taler-wallet-core"; +import { + downloadExchangeInfo, + topupReserveWithBank, +} from "@gnu-taler/taler-wallet-core/dbless"; +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, + WalletClient, + delayMs, + runTestWithState, +} from "./harness/harness.js"; +import { + createSimpleTestkudosEnvironmentV2, + createWalletDaemonWithClient, +} from "./harness/helpers.js"; +import { getTestInfo, runTests } from "./integrationtests/testrunner.js"; +import { lintExchangeDeployment } from "./lint.js"; +import { randomUUID } from "crypto"; + +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; +declare const __GIT_HASH__: string; +function printVersion(): void { + console.log(`${__VERSION__} ${__GIT_HASH__}`); + 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); + }); + +async function doDbChecks( + t: GlobalTestState, + walletClient: WalletClient, + indir: string, +): Promise<void> { + // Check that balance didn't break + const balPath = `${indir}/wallet-balances.json`; + const expectedBal: BalancesResponse = JSON.parse( + fs.readFileSync(balPath, { encoding: "utf8" }), + ) as BalancesResponse; + const actualBal = await walletClient.call(WalletApiOperation.GetBalances, {}); + t.assertDeepEqual(actualBal.balances.length, expectedBal.balances.length); + + // Check that transactions didn't break + const txnPath = `${indir}/wallet-transactions.json`; + const expectedTxn: TransactionsResponse = JSON.parse( + fs.readFileSync(txnPath, { encoding: "utf8" }), + ) as TransactionsResponse; + const actualTxn = await walletClient.call( + WalletApiOperation.GetTransactions, + { includeRefreshes: true }, + ); + t.assertDeepEqual( + actualTxn.transactions.length, + expectedTxn.transactions.length, + ); +} + +advancedCli + .subcommand("walletDbcheck", "wallet-dbcheck", { + help: "Check a wallet database (used for migration testing).", + }) + .requiredArgument("indir", clk.STRING) + .action(async (args) => { + const indir = args.walletDbcheck.indir; + if (!fs.existsSync(indir)) { + throw Error("directory to be checked does not exist"); + } + + const testRootDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-dbchk-")); + const t: GlobalTestState = new GlobalTestState({ + testDir: testRootDir, + }); + const origWalletDbPath = `${indir}/wallet-db.sqlite3`; + const testWalletDbPath = `${testRootDir}/wallet-testdb.sqlite3`; + fs.cpSync(origWalletDbPath, testWalletDbPath); + if (!fs.existsSync(origWalletDbPath)) { + throw new Error("wallet db to be checked does not exist"); + } + const { walletClient, walletService } = await createWalletDaemonWithClient( + t, + { name: "wallet-loaded", overrideDbPath: testWalletDbPath }, + ); + + await walletService.pingUntilAvailable(); + + // Do DB checks with the DB we loaded. + await doDbChecks(t, walletClient, indir); + + const { + walletClient: freshWalletClient, + walletService: freshWalletService, + } = await createWalletDaemonWithClient(t, { + name: "wallet-fresh", + persistent: false, + }); + + await freshWalletService.pingUntilAvailable(); + + // Check that we can still import the backup JSON. + + const backupPath = `${indir}/wallet-backup.json`; + const backupData = JSON.parse( + fs.readFileSync(backupPath, { encoding: "utf8" }), + ); + await freshWalletClient.call(WalletApiOperation.ImportDb, { + dump: backupData, + }); + + // Repeat same checks with wallet that we restored from backup + // instead of from the DB file. + await doDbChecks(t, freshWalletClient, indir); + + await t.shutdown(); + }); + +advancedCli + .subcommand("walletDbgen", "wallet-dbgen", { + help: "Generate a wallet test database (to be used for migration testing).", + }) + .requiredArgument("outdir", clk.STRING) + .action(async (args) => { + const outdir = args.walletDbgen.outdir; + if (fs.existsSync(outdir)) { + throw new Error("outdir already exists, please delete first"); + } + fs.mkdirSync(outdir, { + recursive: true, + }); + + const testRootDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-dbgen-")); + console.log(`generating data in ${testRootDir}`); + const t = new GlobalTestState({ + testDir: testRootDir, + }); + const { walletClient, walletService, bank, exchange, merchant } = + await createSimpleTestkudosEnvironmentV2(t); + await walletClient.call(WalletApiOperation.RunIntegrationTestV2, { + amountToSpend: "TESTKUDOS:5" as AmountString, + amountToWithdraw: "TESTKUDOS:10" as AmountString, + corebankApiBaseUrl: bank.corebankApiBaseUrl, + exchangeBaseUrl: exchange.baseUrl, + merchantBaseUrl: merchant.makeInstanceBaseUrl(), + }); + await walletClient.call( + WalletApiOperation.TestingWaitTransactionsFinal, + {}, + ); + + const transactionsJson = await walletClient.call( + WalletApiOperation.GetTransactions, + { + includeRefreshes: true, + }, + ); + + const balancesJson = await walletClient.call( + WalletApiOperation.GetBalances, + {}, + ); + + const backupJson = await walletClient.call(WalletApiOperation.ExportDb, {}); + + const versionJson = await walletClient.call( + WalletApiOperation.GetVersion, + {}, + ); + + await walletService.stop(); + + await t.shutdown(); + + console.log(`generated data in ${testRootDir}`); + + fs.copyFileSync(walletService.dbPath, `${outdir}/wallet-db.sqlite3`); + fs.writeFileSync( + `${outdir}/wallet-transactions.json`, + j2s(transactionsJson), + ); + fs.writeFileSync(`${outdir}/wallet-balances.json`, j2s(balancesJson)); + fs.writeFileSync(`${outdir}/wallet-backup.json`, j2s(backupJson)); + fs.writeFileSync(`${outdir}/wallet-version.json`, j2s(versionJson)); + fs.writeFileSync( + `${outdir}/meta.json`, + j2s({ + timestamp: new Date(), + }), + ); + }); + +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("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 topupReserveWithBank({ + amount: "KUDOS:10" as AmountString, + 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 topupReserveWithBank({ + amount: "TESTKUDOS:10" as AmountString, + 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 topupReserveWithBank({ + amount: "TESTKUDOS:10" as AmountString, + 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("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-endpoint", { + 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("provisionBankMerchant", "provision-bank-and-merchant", { + help: "Provision a bank account, merchant instance and link them together.", + }) + .requiredArgument("merchantApiBaseUrl", clk.STRING, { + help: "URL location of the merchant backend", + }) + .requiredArgument("corebankApiBaseUrl", clk.STRING, { + help: "URL location of the libeufin bank backend", + }) + .requiredOption( + "merchantToken", + ["--merchant-management-token"], + clk.STRING, + { + help: "access token of the default instance in the merchant backend", + }, + ) + .maybeOption("bankToken", ["--bank-admin-token"], clk.STRING, { + help: "libeufin bank admin's token if the account creation is restricted", + }) + .maybeOption("bankPassword", ["--bank-admin-password"], clk.STRING, { + help: "libeufin bank admin's password if the account creation is restricted, it will override --bank-admin-token", + }) + .requiredOption("name", ["--legal-name"], clk.STRING, { + help: "legal name of the merchant", + }) + .maybeOption("email", ["--email"], clk.STRING, { + help: "email contact of the merchant", + }) + .maybeOption("phone", ["--phone"], clk.STRING, { + help: "phone contact of the merchant", + }) + .requiredOption("id", ["--id"], clk.STRING, { + help: "login id for the bank account and instance id of the merchant backend", + }) + .flag("template", ["--create-template"], { + help: "use this flag to create a default template for the merchant with fixed summary", + }) + .requiredOption("password", ["--password"], clk.STRING, { + help: "password of the accounts in libeufin bank and merchant backend", + }) + .flag("randomPassword", ["--set-random-password"], { + help: "if everything worked ok, change the password of the accounts at the end", + }) + .action(async (args) => { + const managementToken = createAccessToken( + args.provisionBankMerchant.merchantToken, + ); + const bankAdminPassword = args.provisionBankMerchant.bankPassword; + const bankAdminTokenArg = args.provisionBankMerchant.bankToken + ? createAccessToken(args.provisionBankMerchant.bankToken) + : undefined; + const id = args.provisionBankMerchant.id; + const name = args.provisionBankMerchant.name; + const email = args.provisionBankMerchant.email; + const phone = args.provisionBankMerchant.phone; + const password = args.provisionBankMerchant.password; + + const httpLib = createPlatformHttpLib({}); + const merchantManager = new TalerMerchantManagementHttpClient( + args.provisionBankMerchant.merchantApiBaseUrl, + httpLib, + ); + const bank = new TalerCoreBankHttpClient( + args.provisionBankMerchant.corebankApiBaseUrl, + httpLib, + ); + const instanceURL = merchantManager.getSubInstanceAPI(id).href; + const merchantInstance = new TalerMerchantInstanceHttpClient( + instanceURL, + httpLib, + ); + const conv = new TalerBankConversionHttpClient( + bank.getConversionInfoAPI().href, + httpLib, + ); + const bankAuth = new TalerAuthenticationHttpClient( + bank.getAuthenticationAPI(id).href, + httpLib, + ); + + const bc = await bank.getConfig(); + if (bc.type === "fail") { + logger.error(`couldn't get bank config. ${bc.detail.hint}`); + return; + } + if (!bank.isCompatible(bc.body.version)) { + logger.error( + `bank server version is not compatible: ${bc.body.version}, client version: ${bank.PROTOCOL_VERSION}`, + ); + return; + } + const mc = await merchantManager.getConfig(); + if (mc.type === "fail") { + logger.error(`couldn't get merchant config. ${mc.detail.hint}`); + return; + } + if (!merchantManager.isCompatible(mc.body.version)) { + logger.error( + `merchant server version is not compatible: ${mc.body.version}, client version: ${merchantManager.PROTOCOL_VERSION}`, + ); + return; + } + + let bankAdminToken: AccessToken | undefined; + if (bankAdminPassword) { + const adminAuth = new TalerAuthenticationHttpClient( + bank.getAuthenticationAPI("admin").href, + httpLib, + ); + + const resp = await adminAuth.createAccessTokenBasic( + "admin", + bankAdminPassword, + { + scope: "write", + duration: { + d_us: 1000 * 1000 * 10, //10 secs + }, + refreshable: false, + }, + ); + if (resp.type === "fail") { + logger.error(`could not get bank admin token from password.`); + return; + } + bankAdminToken = resp.body.access_token; + } else { + bankAdminToken = bankAdminTokenArg; + } + + /** + * create bank account + */ + let accountPayto: PaytoString; + { + const resp = await bank.createAccount(bankAdminToken, { + name: name, + password: password, + username: id, + contact_data: + email || phone + ? { + email: email, + phone: phone, + } + : undefined, + }); + + if (resp.type === "fail") { + logger.error( + `unable to provision bank account, HTTP response status ${resp.case}`, + ); + process.exit(2); + } + logger.info(`account ${id} successfully provisioned`); + accountPayto = resp.body.internal_payto_uri; + } + + /** + * create merchant account + */ + { + const resp = await merchantManager.createInstance(managementToken, { + address: {}, + auth: { + method: "token", + token: createAccessToken(password), + }, + default_pay_delay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ hours: 1 }), + ), + default_wire_transfer_delay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ hours: 1 }), + ), + id: id, + jurisdiction: {}, + name: name, + use_stefan: true, + }); + + if (resp.type === "ok") { + logger.info(`instance ${id} created successfully`); + } else if (resp.case === HttpStatusCode.Conflict) { + logger.info(`instance ${id} already exists`); + } else { + logger.error( + `unable to create instance ${id}, HTTP status ${resp.case}`, + ); + process.exit(2); + } + } + + let wireAccount: string; + /** + * link bank account and merchant + */ + { + const resp = await merchantInstance.addBankAccount( + createAccessToken(password), + { + payto_uri: accountPayto, + credit_facade_url: bank.getRevenueAPI(id).href, + credit_facade_credentials: { + type: "basic", + username: id, + password: password, + }, + }, + ); + if (resp.type === "fail") { + console.error( + `unable to configure bank account for instance ${id}, status ${resp.case}`, + ); + console.error(j2s(resp.detail)); + process.exit(2); + } + wireAccount = resp.body.h_wire; + } + + logger.info(`successfully configured bank account for ${id}`); + + let templateURI; + /** + * create template + */ + if (args.provisionBankMerchant.template) { + let currency = bc.body.currency; + if (bc.body.allow_conversion) { + const cc = await conv.getConfig(); + if (cc.type === "ok") { + currency = cc.body.fiat_currency; + } else { + console.error(`could not get fiat currency status ${cc.case}`); + console.error(j2s(cc.detail)); + } + } else { + console.log(`conversion is disabled, using bank currency`); + } + + { + const resp = await merchantInstance.addTemplate( + createAccessToken(password), + { + template_id: "default", + template_description: "First template", + template_contract: { + pay_duration: Duration.toTalerProtocolDuration( + Duration.fromSpec({ hours: 1 }), + ), + minimum_age: 0, + currency, + summary: "Pay me!", + }, + }, + ); + if (resp.type === "fail") { + console.error( + `unable to create template for insntaince ${id}, status ${resp.case}`, + ); + console.error(j2s(resp.detail)); + process.exit(2); + } + } + + logger.info(`template default successfully created`); + templateURI = stringifyPayTemplateUri({ + merchantBaseUrl: instanceURL, + templateId: "default", + templateParams: { + amount: currency, + }, + }); + } + + let finalPassword = password; + if (args.provisionBankMerchant.randomPassword) { + const prevPassword = password; + const randomPassword = encodeCrock(randomBytes(16)); + logger.info("random password: ", randomPassword); + let token: AccessToken; + { + const resp = await bankAuth.createAccessTokenBasic(id, prevPassword, { + scope: "readwrite", + duration: Duration.toTalerProtocolDuration( + Duration.fromSpec({ minutes: 1 }), + ), + refreshable: false, + }); + if (resp.type === "fail") { + console.error( + `unable to login into bank accountfor user ${id}, status ${resp.case}`, + ); + console.error(j2s(resp.detail)); + process.exit(2); + } + token = resp.body.access_token; + } + + { + const resp = await bank.updatePassword( + { username: id, token }, + { + old_password: prevPassword, + new_password: randomPassword, + }, + ); + if (resp.type === "fail") { + console.error( + `unable to change bank password for user ${id}, status ${resp.case}`, + ); + if (resp.case !== HttpStatusCode.Accepted) { + console.error(j2s(resp.detail)); + } else { + console.error("2FA required"); + } + process.exit(2); + } + } + + { + const resp = await merchantInstance.updateCurrentInstanceAuthentication( + createAccessToken(prevPassword), + { + method: "token", + token: createAccessToken(randomPassword), + }, + ); + if (resp.type === "fail") { + console.error( + `unable to change merchant password for instance ${id}, status ${resp.case}`, + ); + console.error(j2s(resp.detail)); + process.exit(2); + } + } + + { + const resp = await merchantInstance.updateBankAccount( + createAccessToken(randomPassword), + wireAccount, + { + credit_facade_url: bank.getRevenueAPI(id).href, + credit_facade_credentials: { + type: "basic", + username: id, + password: randomPassword, + }, + }, + ); + if (resp.type != "ok") { + console.error( + `unable to update bank account for instance ${id}, status ${resp.case}`, + ); + console.error(j2s(resp.detail)); + process.exit(2); + } + } + finalPassword = randomPassword; + } + logger.info(`successfully configured bank account for ${id}`); + + /** + * show result + */ + console.log( + JSON.stringify( + { + bankUser: id, + bankURL: args.provisionBankMerchant.corebankApiBaseUrl, + merchantURL: instanceURL, + templateURI, + password: finalPassword, + }, + undefined, + 2, + ), + ); + }); + +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) + .maybeOption("bankURL", ["--bankURL"], clk.STRING) + .maybeOption("bankUser", ["--bankUser"], clk.STRING) + .maybeOption("bankPassword", ["--bankPassword"], clk.STRING) + .action(async (args) => { + const httpLib = createPlatformHttpLib({}); + const baseUrl = args.provisionMerchantInstance.merchantApiBaseUrl; + const api = new TalerMerchantManagementHttpClient(baseUrl, httpLib); + const managementToken = createAccessToken( + args.provisionMerchantInstance.managementToken, + ); + const instanceToken = createAccessToken( + args.provisionMerchantInstance.instanceToken, + ); + const instanceId = args.provisionMerchantInstance.id; + const instancceName = args.provisionMerchantInstance.name; + const bankURL = args.provisionMerchantInstance.bankURL; + const bankUser = args.provisionMerchantInstance.bankUser; + const bankPassword = args.provisionMerchantInstance.bankPassword; + const accountPayto = args.provisionMerchantInstance.payto as PaytoString; + + const createResp = await api.createInstance(managementToken, { + address: {}, + auth: { + method: "token", + token: instanceToken, + }, + default_pay_delay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ hours: 1 }), + ), + default_wire_transfer_delay: { d_us: 1 }, + id: instanceId, + jurisdiction: {}, + name: instancceName, + use_stefan: true, + }); + + if (createResp.type === "ok") { + logger.info(`instance ${instanceId} created successfully`); + } else if (createResp.case === HttpStatusCode.Conflict) { + logger.info(`instance ${instanceId} already exists`); + } else { + logger.error( + `unable to create instance ${instanceId}, HTTP status ${createResp.case}`, + ); + process.exit(2); + } + + const createAccountResp = await api.addBankAccount(instanceToken, { + payto_uri: accountPayto, + credit_facade_url: bankURL, + credit_facade_credentials: + bankUser && bankPassword + ? { + type: "basic", + username: bankUser, + password: bankPassword, + } + : undefined, + }); + if (createAccountResp.type != "ok") { + console.error( + `unable to configure bank account for instance ${instanceId}, status ${createAccountResp.case}`, + ); + console.error(j2s(createAccountResp.detail)); + 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 baseUrl = args.provisionBankAccount.corebankApiBaseUrl; + const api = new TalerCoreBankHttpClient(baseUrl, httpLib); + + const accountLogin = args.provisionBankAccount.login; + const resp = await api.createAccount(undefined, { + name: args.provisionBankAccount.name, + password: args.provisionBankAccount.password, + username: accountLogin, + is_public: !!args.provisionBankAccount.public, + is_taler_exchange: !!args.provisionBankAccount.exchange, + payto_uri: args.provisionBankAccount.internalPayto as PaytoString, + }); + + if (resp.type === "ok") { + logger.info(`account ${accountLogin} successfully provisioned`); + return; + } + logger.error( + `unable to provision bank account, HTTP response status ${resp.case}`, + ); + 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", + }) + .flag("noFees", ["--no-fees"]) + .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`; + if (args.coincfg.noFees) { + out += `FEE_DEPOSIT = ${currency}:0\n`; + } else { + 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<string, string> = {}; + + 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(); +} |