/*
 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 
 */
/**
 * Test harness for various GNU Taler components.
 * Also provides a fault-injection proxy.
 *
 * @author Florian Dold 
 */
/**
 * Imports
 */
import {
  AccountAddDetails,
  AmountJson,
  Amounts,
  TalerCorebankApiClient,
  Configuration,
  CoreApiResponse,
  Duration,
  EddsaKeyPair,
  Logger,
  MerchantInstanceConfig,
  PartialMerchantInstanceConfig,
  TalerError,
  WalletNotification,
  createEddsaKeyPair,
  eddsaGetPublic,
  encodeCrock,
  hash,
  j2s,
  parsePaytoUri,
  stringToBytes,
} from "@gnu-taler/taler-util";
import {
  HttpRequestLibrary,
  createPlatformHttpLib,
  expectSuccessResponseOrThrow,
} from "@gnu-taler/taler-util/http";
import {
  WalletCoreApiClient,
  WalletCoreRequestType,
  WalletCoreResponseType,
  WalletOperations,
  openPromise,
} from "@gnu-taler/taler-wallet-core";
import {
  RemoteWallet,
  WalletNotificationWaiter,
  createRemoteWallet,
  getClientFromRemoteWallet,
  makeNotificationWaiter,
} from "@gnu-taler/taler-wallet-core/remote";
import { deepStrictEqual } from "assert";
import { ChildProcess, spawn } from "child_process";
import * as fs from "fs";
import * as http from "http";
import * as net from "node:net";
import * as path from "path";
import * as readline from "readline";
import { URL } from "url";
import { CoinConfig } from "./denomStructures.js";
const logger = new Logger("harness.ts");
export async function delayMs(ms: number): Promise {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(), ms);
  });
}
export interface WithAuthorization {
  Authorization?: string;
}
interface WaitResult {
  code: number | null;
  signal: NodeJS.Signals | null;
}
class CommandError extends Error {
  constructor(
    public message: string,
    public logName: string,
    public command: string,
    public args: string[],
    public env: Env,
    public code: number | null,
  ) {
    super(message);
  }
}
interface Env {
  [index: string]: string | undefined;
}
/**
 * Run a shell command, return stdout.
 */
export async function sh(
  t: GlobalTestState,
  logName: string,
  command: string,
  env: Env = process.env,
): Promise {
  logger.trace(`running command ${command}`);
  return new Promise((resolve, reject) => {
    const stdoutChunks: Buffer[] = [];
    const proc = spawn(command, {
      stdio: ["inherit", "pipe", "pipe"],
      shell: true,
      env,
    });
    proc.stdout.on("data", (x) => {
      if (x instanceof Buffer) {
        stdoutChunks.push(x);
      } else {
        throw Error("unexpected data chunk type");
      }
    });
    const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
    const stderrLog = fs.createWriteStream(stderrLogFileName, {
      flags: "a",
    });
    proc.stderr.pipe(stderrLog);
    proc.on("exit", (code, signal) => {
      logger.info(`child process ${logName} exited (${code} / ${signal})`);
      if (code != 0) {
        reject(
          new CommandError(
            `Unexpected exit code ${code}`,
            logName,
            command,
            [],
            env,
            code,
          ),
        );
        return;
      }
      const b = Buffer.concat(stdoutChunks).toString("utf-8");
      resolve(b);
    });
    proc.on("error", (err) => {
      reject(
        new CommandError(
          "Child process had error:" + err.message,
          logName,
          command,
          [],
          env,
          null,
        ),
      );
    });
  });
}
function shellescape(args: string[]) {
  const ret = args.map((s) => {
    if (/[^A-Za-z0-9_\/:=-]/.test(s)) {
      s = "'" + s.replace(/'/g, "'\\''") + "'";
      s = s.replace(/^(?:'')+/g, "").replace(/\\'''/g, "\\'");
    }
    return s;
  });
  return ret.join(" ");
}
/**
 * Run a shell command, return stdout.
 *
 * Log stderr to a log file.
 */
export async function runCommand(
  t: GlobalTestState,
  logName: string,
  command: string,
  args: string[],
  env: { [index: string]: string | undefined } = process.env,
): Promise {
  logger.info(`running command ${shellescape([command, ...args])}`);
  return new Promise((resolve, reject) => {
    const stdoutChunks: Buffer[] = [];
    const proc = spawn(command, args, {
      stdio: ["inherit", "pipe", "pipe"],
      shell: false,
      env: env,
    });
    proc.stdout.on("data", (x) => {
      if (x instanceof Buffer) {
        stdoutChunks.push(x);
      } else {
        throw Error("unexpected data chunk type");
      }
    });
    const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
    const stderrLog = fs.createWriteStream(stderrLogFileName, {
      flags: "a",
    });
    proc.stderr.pipe(stderrLog);
    proc.on("exit", (code, signal) => {
      logger.trace(`child process exited (${code} / ${signal})`);
      if (code != 0) {
        reject(
          new CommandError(
            `Unexpected exit code ${code}`,
            logName,
            command,
            [],
            env,
            code,
          ),
        );
        return;
      }
      const b = Buffer.concat(stdoutChunks).toString("utf-8");
      resolve(b);
    });
    proc.on("error", (err) => {
      reject(
        new CommandError(
          "Child process had error:" + err.message,
          logName,
          command,
          [],
          env,
          null,
        ),
      );
    });
  });
}
export class ProcessWrapper {
  private waitPromise: Promise;
  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 {
    return this.waitPromise;
  }
}
export class GlobalTestParams {
  testDir: string;
}
export class GlobalTestState {
  testDir: string;
  procs: ProcessWrapper[];
  servers: http.Server[];
  inShutdown: boolean = false;
  constructor(params: GlobalTestParams) {
    this.testDir = params.testDir;
    this.procs = [];
    this.servers = [];
  }
  async assertThrowsTalerErrorAsync(
    block: () => Promise,
  ): Promise {
    try {
      await block();
    } catch (e) {
      if (e instanceof TalerError) {
        return e;
      }
      throw Error(`expected TalerError to be thrown, but got ${e}`);
    }
    throw Error(
      `expected TalerError to be thrown, but block finished without throwing`,
    );
  }
  async assertThrowsAsync(block: () => Promise): Promise {
    try {
      await block();
    } catch (e) {
      return e;
    }
    throw Error(
      `expected exception to be thrown, but block finished without throwing`,
    );
  }
  assertTrue(b: boolean): asserts b {
    if (!b) {
      throw Error("test assertion failed");
    }
  }
  assertDeepEqual(actual: any, expected: T): asserts actual is T {
    deepStrictEqual(actual, expected);
  }
  assertAmountEquals(
    amtActual: string | AmountJson,
    amtExpected: string | AmountJson,
  ): void {
    if (Amounts.cmp(amtActual, amtExpected) != 0) {
      throw Error(
        `test assertion failed: expected ${Amounts.stringify(
          amtExpected,
        )} but got ${Amounts.stringify(amtActual)}`,
      );
    }
  }
  assertAmountLeq(a: string | AmountJson, b: string | AmountJson): void {
    if (Amounts.cmp(a, b) > 0) {
      throw Error(
        `test assertion failed: expected ${Amounts.stringify(
          a,
        )} to be less or equal (leq) than ${Amounts.stringify(b)}`,
      );
    }
  }
  shutdownSync(): void {
    for (const s of this.servers) {
      s.close();
      s.removeAllListeners();
    }
    for (const p of this.procs) {
      if (p.proc.exitCode == null) {
        p.proc.kill("SIGTERM");
      }
    }
  }
  spawnService(
    command: string,
    args: string[],
    logName: string,
    env: { [index: string]: string | undefined } = process.env,
  ): ProcessWrapper {
    logger.trace(
      `spawning process (${logName}): ${shellescape([command, ...args])}`,
    );
    const proc = spawn(command, args, {
      stdio: ["inherit", "pipe", "pipe"],
      env: env,
    });
    logger.trace(`spawned process (${logName}) with pid ${proc.pid}`);
    proc.on("error", (err) => {
      logger.warn(`could not start process (${command})`, err);
    });
    proc.on("exit", (code, signal) => {
      if (code == 0 && signal == null) {
        logger.info(`process ${logName} exited with success`);
      } else {
        logger.warn(`process ${logName} exited ${j2s({ code, signal })}`);
      }
    });
    const stderrLogFileName = this.testDir + `/${logName}-stderr.log`;
    const stderrLog = fs.createWriteStream(stderrLogFileName, {
      flags: "a",
    });
    proc.stderr.pipe(stderrLog);
    const stdoutLogFileName = this.testDir + `/${logName}-stdout.log`;
    const stdoutLog = fs.createWriteStream(stdoutLogFileName, {
      flags: "a",
    });
    proc.stdout.pipe(stdoutLog);
    const procWrap = new ProcessWrapper(proc);
    this.procs.push(procWrap);
    return procWrap;
  }
  async shutdown(): Promise {
    if (this.inShutdown) {
      return;
    }
    if (shouldLingerInTest()) {
      logger.trace("refusing to shut down, lingering was requested");
      return;
    }
    this.inShutdown = true;
    logger.trace("shutting down");
    for (const s of this.servers) {
      s.close();
      s.removeAllListeners();
    }
    for (const p of this.procs) {
      if (p.proc.exitCode == null) {
        logger.trace(`killing process ${p.proc.pid}`);
        p.proc.kill("SIGTERM");
        await p.wait();
      }
    }
  }
}
export function shouldLingerInTest(): boolean {
  return !!process.env["TALER_TEST_LINGER"];
}
export interface TalerConfigSection {
  options: Record;
}
export interface TalerConfig {
  sections: Record;
}
export interface DbInfo {
  /**
   * Postgres connection string.
   */
  connStr: string;
  dbname: string;
}
export async function setupDb(t: GlobalTestState): Promise {
  const dbname = "taler-integrationtest";
  try {
    await runCommand(t, "dropdb", "dropdb", [dbname]);
  } catch (e: any) {
    logger.warn(`dropdb failed: ${e.toString()}`);
  }
  await runCommand(t, "createdb", "createdb", [dbname]);
  return {
    connStr: `postgres:///${dbname}`,
    dbname,
  };
}
/**
 * Make sure that the taler-integrationtest-shared database exists.
 * Don't delete it if it already exists.
 */
export async function setupSharedDb(t: GlobalTestState): Promise {
  const dbname = "taler-integrationtest-shared";
  const databases = await runCommand(t, "list-dbs", "psql", ["-Aqtl"]);
  if (databases.indexOf("taler-integrationtest-shared") < 0) {
    await runCommand(t, "createdb", "createdb", [dbname]);
  }
  return {
    connStr: `postgres:///${dbname}`,
    dbname,
  };
}
export interface BankConfig {
  currency: string;
  httpPort: number;
  database: string;
  allowRegistrations: boolean;
  maxDebt?: string;
  overrideTestDir?: string;
}
export interface FakeBankConfig {
  currency: string;
  httpPort: number;
}
function setTalerPaths(config: Configuration, home: string) {
  config.setString("paths", "taler_home", home);
  // We need to make sure that the path of taler_runtime_dir isn't too long,
  // as it contains unix domain sockets (108 character limit).
  const runDir = fs.mkdtempSync("/tmp/taler-test-");
  config.setString("paths", "taler_runtime_dir", runDir);
  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/");
}
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);
  if (c.ageRestricted) {
    config.setString(s, "age_restricted", "yes");
  }
  if (c.cipher === "RSA") {
    config.setString(s, "rsa_keysize", `${c.rsaKeySize}`);
    config.setString(s, "cipher", "RSA");
  } else if (c.cipher === "CS") {
    config.setString(s, "cipher", "CS");
  } else {
    throw new Error();
  }
}
function backoffStart(): number {
  return 10;
}
function backoffIncrement(n: number): number {
  return Math.min(Math.floor(n * 1.5), 1000);
}
/**
 * Send an HTTP request until it succeeds or the process dies.
 */
export async function pingProc(
  proc: ProcessWrapper | undefined,
  url: string,
  serviceName: string,
): Promise {
  if (!proc || proc.proc.exitCode !== null) {
    throw Error(`service process ${serviceName} not started, can't ping`);
  }
  let nextDelay = backoffStart();
  while (true) {
    try {
      logger.trace(`pinging ${serviceName} at ${url}`);
      const resp = await harnessHttpLib.fetch(url);
      if (resp.status !== 200) {
        throw Error("non-200 status code");
      }
      logger.trace(`service ${serviceName} available`);
      return;
    } catch (e: any) {
      logger.warn(`service ${serviceName} not ready:`, e.toString());
      logger.info(`waiting ${nextDelay}ms on ${serviceName}`);
      await delayMs(nextDelay);
      nextDelay = backoffIncrement(nextDelay);
    }
    if (!proc || proc.proc.exitCode != null || proc.proc.signalCode != null) {
      throw Error(`service process ${serviceName} stopped unexpectedly`);
    }
  }
}
class BankServiceBase {
  proc: ProcessWrapper | undefined;
  protected constructor(
    protected globalTestState: GlobalTestState,
    protected bankConfig: BankConfig,
    protected configFile: string,
  ) {}
}
export interface HarnessExchangeBankAccount {
  accountName: string;
  accountPassword: string;
  accountPaytoUri: string;
  wireGatewayApiBaseUrl: string;
}
/**
 * Implementation of the bank service using the "taler-fakebank-run" tool.
 */
export class FakebankService
  extends BankServiceBase
  implements BankServiceHandle
{
  proc: ProcessWrapper | undefined;
  http = createPlatformHttpLib({ enableThrottling: false });
  // We store "created" accounts during setup and
  // register them after startup.
  private accounts: {
    accountName: string;
    accountPassword: string;
  }[] = [];
  /**
   * Create a new fakebank service handle.
   *
   * First generates the configuration for the fakebank and
   * then creates a fakebank handle, but doesn't start the fakebank
   * service yet.
   */
  static async create(
    gc: GlobalTestState,
    bc: BankConfig,
  ): Promise {
    const config = new Configuration();
    const testDir = bc.overrideTestDir ?? gc.testDir;
    setTalerPaths(config, testDir + "/talerhome");
    config.setString("taler", "currency", bc.currency);
    config.setString("bank", "http_port", `${bc.httpPort}`);
    config.setString("bank", "serve", "http");
    config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
    config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`);
    config.setString("bank", "ram_limit", `${1024}`);
    const cfgFilename = testDir + "/bank.conf";
    config.write(cfgFilename, { excludeDefaults: true });
    return new FakebankService(gc, bc, cfgFilename);
  }
  static fromExistingConfig(
    gc: GlobalTestState,
    opts: { overridePath?: string },
  ): FakebankService {
    const testDir = opts.overridePath ?? gc.testDir;
    const cfgFilename = testDir + `/bank.conf`;
    const config = Configuration.load(cfgFilename);
    const bc: BankConfig = {
      allowRegistrations:
        config.getYesNo("bank", "allow_registrations").orUndefined() ?? true,
      currency: config.getString("taler", "currency").required(),
      database: "none",
      httpPort: config.getNumber("bank", "http_port").required(),
      maxDebt: config.getString("bank", "max_debt").required(),
    };
    return new FakebankService(gc, bc, cfgFilename);
  }
  setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) {
    if (!!this.proc) {
      throw Error("Can't set suggested exchange while bank is running.");
    }
    const config = Configuration.load(this.configFile);
    config.setString("bank", "suggested_exchange", e.baseUrl);
    config.write(this.configFile, { excludeDefaults: true });
  }
  get baseUrl(): string {
    return `http://localhost:${this.bankConfig.httpPort}/`;
  }
  get bankAccessApiBaseUrl(): string {
    return this.baseUrl;
  }
  async createExchangeAccount(
    accountName: string,
    password: string,
  ): Promise {
    this.accounts.push({
      accountName,
      accountPassword: password,
    });
    return {
      accountName: accountName,
      accountPassword: password,
      accountPaytoUri: generateRandomPayto(accountName),
      wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/accounts/${accountName}/taler-wire-gateway/`,
    };
  }
  get port() {
    return this.bankConfig.httpPort;
  }
  async start(): Promise {
    logger.info("starting fakebank");
    if (this.proc) {
      logger.info("fakebank already running, not starting again");
      return;
    }
    this.proc = this.globalTestState.spawnService(
      "taler-fakebank-run",
      [
        "-c",
        this.configFile,
        "--signup-bonus",
        `${this.bankConfig.currency}:100`,
      ],
      "bank",
    );
    await this.pingUntilAvailable();
    const bankClient = new TalerCorebankApiClient(this.bankAccessApiBaseUrl);
    for (const acc of this.accounts) {
      await bankClient.registerAccount(acc.accountName, acc.accountPassword);
    }
  }
  async pingUntilAvailable(): Promise {
    const url = `http://localhost:${this.bankConfig.httpPort}/config`;
    await pingProc(this.proc, url, "bank");
  }
}
/**
 * Implementation of the bank service using the "taler-fakebank-run" tool.
 */
export class LibeufinBankService
  extends BankServiceBase
  implements BankServiceHandle
{
  proc: ProcessWrapper | undefined;
  http = createPlatformHttpLib({ enableThrottling: false });
  // We store "created" accounts during setup and
  // register them after startup.
  private accounts: {
    accountName: string;
    accountPassword: string;
  }[] = [];
  /**
   * Create a new fakebank service handle.
   *
   * First generates the configuration for the fakebank and
   * then creates a fakebank handle, but doesn't start the fakebank
   * service yet.
   */
  static async create(
    gc: GlobalTestState,
    bc: BankConfig,
  ): Promise {
    const config = new Configuration();
    const testDir = bc.overrideTestDir ?? gc.testDir;
    setTalerPaths(config, testDir + "/talerhome");
    config.setString("libeufin-bankdb-postgres", "config", bc.database);
    config.setString("libeufin-bank", "currency", bc.currency);
    config.setString("libeufin-bank", "port", `${bc.httpPort}`);
    config.setString("libeufin-bank", "serve", "tcp");
    config.setString(
      "libeufin-bank",
      "DEFAULT_CUSTOMER_DEBT_LIMIT",
      `${bc.currency}:500`,
    );
    config.setString(
      "libeufin-bank",
      "DEFAULT_ADMIN_DEBT_LIMIT",
      `${bc.currency}:999999`,
    );
    config.setString(
      "libeufin-bank",
      "registration_bonus",
      `${bc.currency}:100`,
    );
    config.setString("libeufin-bank", "registration_bonus_enabled", `yes`);
    config.setString("libeufin-bank", "max_auth_token_duration", "1h");
    const cfgFilename = testDir + "/bank.conf";
    config.write(cfgFilename, { excludeDefaults: true });
    return new LibeufinBankService(gc, bc, cfgFilename);
  }
  static fromExistingConfig(
    gc: GlobalTestState,
    opts: { overridePath?: string },
  ): FakebankService {
    const testDir = opts.overridePath ?? gc.testDir;
    const cfgFilename = testDir + `/bank.conf`;
    const config = Configuration.load(cfgFilename);
    const bc: BankConfig = {
      allowRegistrations:
        config.getYesNo("libeufin-bank", "allow_registrations").orUndefined() ??
        true,
      currency: config.getString("libeufin-bank", "currency").required(),
      database: config
        .getString("libeufin-bankdb-postgres", "config")
        .required(),
      httpPort: config.getNumber("libeufin-bank", "port").required(),
      maxDebt: config
        .getString("libeufin-bank", "DEFAULT_CUSTOMER_DEBT_LIMIT")
        .required(),
    };
    return new FakebankService(gc, bc, cfgFilename);
  }
  setSuggestedExchange(e: ExchangeServiceInterface) {
    if (!!this.proc) {
      throw Error("Can't set suggested exchange while bank is running.");
    }
    const config = Configuration.load(this.configFile);
    config.setString("libeufin-bank", "suggested_withdrawal_exchange", e.baseUrl);
    config.write(this.configFile, { excludeDefaults: true });
  }
  get baseUrl(): string {
    return `http://localhost:${this.bankConfig.httpPort}/`;
  }
  get bankAccessApiBaseUrl(): string {
    return this.baseUrl;
  }
  get port() {
    return this.bankConfig.httpPort;
  }
  async start(): Promise {
    logger.info("starting libeufin-bank");
    if (this.proc) {
      logger.info("libeufin-bank already running, not starting again");
      return;
    }
    await sh(
      this.globalTestState,
      "libeufin-bank-dbinit",
      `libeufin-bank dbinit -r -c "${this.configFile}"`,
    );
    this.proc = this.globalTestState.spawnService(
      "libeufin-bank",
      ["serve", "-c", this.configFile],
      "libeufin-bank-httpd",
    );
    await this.pingUntilAvailable();
    const bankClient = new TalerCorebankApiClient(this.bankAccessApiBaseUrl);
    for (const acc of this.accounts) {
      await bankClient.registerAccount(acc.accountName, acc.accountPassword);
    }
  }
  async pingUntilAvailable(): Promise {
    const url = `http://localhost:${this.bankConfig.httpPort}/config`;
    await pingProc(this.proc, url, "libeufin-bank");
  }
}
// Use libeufin bank instead of pybank.
const useLibeufinBank = false;
export interface BankServiceHandle {
  readonly bankAccessApiBaseUrl: string;
  readonly http: HttpRequestLibrary;
}
export type BankService = BankServiceHandle;
export const BankService = FakebankService;
export interface ExchangeConfig {
  name: string;
  currency: string;
  roundUnit?: string;
  httpPort: number;
  database: string;
  overrideTestDir?: string;
}
export interface ExchangeServiceInterface {
  readonly baseUrl: string;
  readonly port: number;
  readonly name: string;
  readonly masterPub: string;
}
export class ExchangeService implements ExchangeServiceInterface {
  static fromExistingConfig(
    gc: GlobalTestState,
    exchangeName: string,
    opts: { overridePath?: string },
  ) {
    const testDir = opts.overridePath ?? gc.testDir;
    const cfgFilename = testDir + `/exchange-${exchangeName}.conf`;
    const config = Configuration.load(cfgFilename);
    const ec: ExchangeConfig = {
      currency: config.getString("taler", "currency").required(),
      database: config.getString("exchangedb-postgres", "config").required(),
      httpPort: config.getNumber("exchange", "port").required(),
      name: exchangeName,
      roundUnit: config.getString("taler", "currency_round_unit").required(),
    };
    const privFile = config
      .getPath("exchange-offline", "master_priv_file")
      .required();
    const eddsaPriv = fs.readFileSync(privFile);
    const keyPair: EddsaKeyPair = {
      eddsaPriv,
      eddsaPub: eddsaGetPublic(eddsaPriv),
    };
    return new ExchangeService(gc, ec, cfgFilename, keyPair);
  }
  private currentTimetravelOffsetMs: number | undefined;
  setTimetravel(t: number | undefined): void {
    if (this.isRunning()) {
      throw Error("can't set time travel while the exchange is running");
    }
    this.currentTimetravelOffsetMs = t;
  }
  private get timetravelArg(): string | undefined {
    if (this.currentTimetravelOffsetMs != null) {
      // Convert to microseconds
      return `--timetravel=+${this.currentTimetravelOffsetMs * 1000}`;
    }
    return undefined;
  }
  /**
   * Return an empty array if no time travel is set,
   * and an array with the time travel command line argument
   * otherwise.
   */
  private get timetravelArgArr(): string[] {
    const tta = this.timetravelArg;
    if (tta) {
      return [tta];
    }
    return [];
  }
  async runWirewatchOnce() {
    if (useLibeufinBank) {
      // Not even 2 seconds showed to be enough!
      await waitMs(4000);
    }
    await runCommand(
      this.globalState,
      `exchange-${this.name}-wirewatch-once`,
      "taler-exchange-wirewatch",
      [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
    );
  }
  async runAggregatorOnceWithTimetravel(opts: {
    timetravelMicroseconds: number;
  }) {
    let timetravelArgArr = [];
    timetravelArgArr.push(`--timetravel=${opts.timetravelMicroseconds}`);
    await runCommand(
      this.globalState,
      `exchange-${this.name}-aggregator-once`,
      "taler-exchange-aggregator",
      [...timetravelArgArr, "-c", this.configFilename, "-t"],
    );
  }
  async runAggregatorOnce() {
    try {
      await runCommand(
        this.globalState,
        `exchange-${this.name}-aggregator-once`,
        "taler-exchange-aggregator",
        [...this.timetravelArgArr, "-c", this.configFilename, "-t", "-y"],
      );
    } catch (e) {
      logger.info(
        "running aggregator with KYC off didn't work, might be old version, running again",
      );
      await runCommand(
        this.globalState,
        `exchange-${this.name}-aggregator-once`,
        "taler-exchange-aggregator",
        [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
      );
    }
  }
  async runTransferOnce() {
    await runCommand(
      this.globalState,
      `exchange-${this.name}-transfer-once`,
      "taler-exchange-transfer",
      [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
    );
  }
  changeConfig(f: (config: Configuration) => void) {
    const config = Configuration.load(this.configFilename);
    f(config);
    config.write(this.configFilename, { excludeDefaults: true });
  }
  static create(gc: GlobalTestState, e: ExchangeConfig) {
    const testDir = e.overrideTestDir ?? gc.testDir;
    const config = new Configuration();
    setTalerPaths(config, testDir + "/talerhome");
    config.setString("taler", "currency", e.currency);
    // Required by the exchange but not really used yet.
    config.setString("exchange", "aml_threshold", `${e.currency}:1000000`);
    config.setString(
      "taler",
      "currency_round_unit",
      e.roundUnit ?? `${e.currency}:0.01`,
    );
    // Set to a high value to not break existing test cases where the merchant
    // would cover all fees.
    config.setString("exchange", "STEFAN_ABS", `${e.currency}:1`);
    config.setString(
      "exchange",
      "revocation_dir",
      "${TALER_DATA_HOME}/exchange/revocations",
    );
    config.setString("exchange", "max_keys_caching", "forever");
    config.setString("exchange", "db", "postgres");
    config.setString(
      "exchange-offline",
      "master_priv_file",
      "${TALER_DATA_HOME}/exchange/offline-keys/master.priv",
    );
    config.setString("exchange", "serve", "tcp");
    config.setString("exchange", "port", `${e.httpPort}`);
    config.setString("exchangedb-postgres", "config", e.database);
    config.setString("taler-exchange-secmod-eddsa", "lookahead_sign", "20 s");
    config.setString("taler-exchange-secmod-rsa", "lookahead_sign", "20 s");
    const exchangeMasterKey = createEddsaKeyPair();
    config.setString(
      "exchange",
      "master_public_key",
      encodeCrock(exchangeMasterKey.eddsaPub),
    );
    const masterPrivFile = config
      .getPath("exchange-offline", "master_priv_file")
      .required();
    fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true });
    if (fs.existsSync(masterPrivFile)) {
      throw new Error(
        "master priv file already exists, can't create new exchange config",
      );
    }
    fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv));
    const cfgFilename = testDir + `/exchange-${e.name}.conf`;
    config.write(cfgFilename, { excludeDefaults: true });
    return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey);
  }
  addOfferedCoins(offeredCoins: ((curr: string) => CoinConfig)[]) {
    const config = Configuration.load(this.configFilename);
    offeredCoins.forEach((cc) =>
      setCoin(config, cc(this.exchangeConfig.currency)),
    );
    config.write(this.configFilename, { excludeDefaults: true });
  }
  addCoinConfigList(ccs: CoinConfig[]) {
    const config = Configuration.load(this.configFilename);
    ccs.forEach((cc) => setCoin(config, cc));
    config.write(this.configFilename, { excludeDefaults: true });
  }
  enableAgeRestrictions(maskStr: string) {
    const config = Configuration.load(this.configFilename);
    config.setString("exchange-extension-age_restriction", "enabled", "yes");
    config.setString(
      "exchange-extension-age_restriction",
      "age_groups",
      maskStr,
    );
    config.write(this.configFilename, { excludeDefaults: true });
  }
  get masterPub() {
    return encodeCrock(this.keyPair.eddsaPub);
  }
  get port() {
    return this.exchangeConfig.httpPort;
  }
  /**
   * Run a function that modifies the existing exchange configuration.
   * The modified exchange configuration will then be written to the
   * file system.
   */
  async modifyConfig(
    f: (config: Configuration) => Promise,
  ): Promise {
    const config = Configuration.load(this.configFilename);
    await f(config);
    config.write(this.configFilename, { excludeDefaults: true });
  }
  async addBankAccount(
    localName: string,
    exchangeBankAccount: HarnessExchangeBankAccount,
  ): Promise {
    const config = Configuration.load(this.configFilename);
    config.setString(
      `exchange-account-${localName}`,
      "wire_response",
      `\${TALER_DATA_HOME}/exchange/account-${localName}.json`,
    );
    config.setString(
      `exchange-account-${localName}`,
      "payto_uri",
      exchangeBankAccount.accountPaytoUri,
    );
    config.setString(`exchange-account-${localName}`, "enable_credit", "yes");
    config.setString(`exchange-account-${localName}`, "enable_debit", "yes");
    config.setString(
      `exchange-accountcredentials-${localName}`,
      "wire_gateway_url",
      exchangeBankAccount.wireGatewayApiBaseUrl,
    );
    config.setString(
      `exchange-accountcredentials-${localName}`,
      "wire_gateway_auth_method",
      "basic",
    );
    config.setString(
      `exchange-accountcredentials-${localName}`,
      "username",
      exchangeBankAccount.accountName,
    );
    config.setString(
      `exchange-accountcredentials-${localName}`,
      "password",
      exchangeBankAccount.accountPassword,
    );
    config.write(this.configFilename, { excludeDefaults: true });
  }
  exchangeHttpProc: ProcessWrapper | undefined;
  exchangeWirewatchProc: ProcessWrapper | undefined;
  exchangeTransferProc: ProcessWrapper | undefined;
  exchangeAggregatorProc: ProcessWrapper | undefined;
  helperCryptoRsaProc: ProcessWrapper | undefined;
  helperCryptoEddsaProc: ProcessWrapper | undefined;
  helperCryptoCsProc: ProcessWrapper | undefined;
  constructor(
    private globalState: GlobalTestState,
    private exchangeConfig: ExchangeConfig,
    private configFilename: string,
    private keyPair: EddsaKeyPair,
  ) {}
  get name() {
    return this.exchangeConfig.name;
  }
  get baseUrl() {
    return `http://localhost:${this.exchangeConfig.httpPort}/`;
  }
  isRunning(): boolean {
    return !!this.exchangeWirewatchProc || !!this.exchangeHttpProc;
  }
  /**
   * Stop the wirewatch service (which runs by default).
   *
   * Useful for some tests.
   */
  async stopWirewatch(): Promise {
    const wirewatch = this.exchangeWirewatchProc;
    if (wirewatch) {
      wirewatch.proc.kill("SIGTERM");
      await wirewatch.wait();
      this.exchangeWirewatchProc = undefined;
    }
  }
  async startWirewatch(): Promise {
    const wirewatch = this.exchangeWirewatchProc;
    if (wirewatch) {
      logger.warn("wirewatch already running");
    } else {
      this.internalCreateWirewatchProc();
    }
  }
  async stop(): Promise {
    const wirewatch = this.exchangeWirewatchProc;
    if (wirewatch) {
      wirewatch.proc.kill("SIGTERM");
      await wirewatch.wait();
      this.exchangeWirewatchProc = undefined;
    }
    const aggregatorProc = this.exchangeAggregatorProc;
    if (aggregatorProc) {
      aggregatorProc.proc.kill("SIGTERM");
      await aggregatorProc.wait();
      this.exchangeAggregatorProc = undefined;
    }
    const transferProc = this.exchangeTransferProc;
    if (transferProc) {
      transferProc.proc.kill("SIGTERM");
      await transferProc.wait();
      this.exchangeTransferProc = undefined;
    }
    const httpd = this.exchangeHttpProc;
    if (httpd) {
      httpd.proc.kill("SIGTERM");
      await httpd.wait();
      this.exchangeHttpProc = undefined;
    }
    const cryptoRsa = this.helperCryptoRsaProc;
    if (cryptoRsa) {
      cryptoRsa.proc.kill("SIGTERM");
      await cryptoRsa.wait();
      this.helperCryptoRsaProc = undefined;
    }
    const cryptoEddsa = this.helperCryptoEddsaProc;
    if (cryptoEddsa) {
      cryptoEddsa.proc.kill("SIGTERM");
      await cryptoEddsa.wait();
      this.helperCryptoRsaProc = undefined;
    }
    const cryptoCs = this.helperCryptoCsProc;
    if (cryptoCs) {
      cryptoCs.proc.kill("SIGTERM");
      await cryptoCs.wait();
      this.helperCryptoCsProc = undefined;
    }
  }
  /**
   * Update keys signing the keys generated by the security module
   * with the offline signing key.
   */
  async keyup(): Promise {
    await runCommand(
      this.globalState,
      "exchange-offline",
      "taler-exchange-offline",
      ["-c", this.configFilename, "download", "sign", "upload"],
    );
    const accounts: string[] = [];
    const accountTargetTypes: Set = new Set();
    const config = Configuration.load(this.configFilename);
    for (const sectionName of config.getSectionNames()) {
      if (sectionName.startsWith("EXCHANGE-ACCOUNT-")) {
        const paytoUri = config.getString(sectionName, "payto_uri").required();
        const p = parsePaytoUri(paytoUri);
        if (!p) {
          throw Error(`invalid payto uri in exchange config: ${paytoUri}`);
        }
        accountTargetTypes.add(p?.targetType);
        accounts.push(paytoUri);
      }
    }
    const accountsDescription = accounts.map((acc) => ` * ${acc}`).join("\n");
    logger.info("configuring bank accounts:");
    logger.info(accountsDescription);
    for (const acc of accounts) {
      await runCommand(
        this.globalState,
        "exchange-offline",
        "taler-exchange-offline",
        ["-c", this.configFilename, "enable-account", acc, "upload"],
      );
    }
    const year = new Date().getFullYear();
    for (const accTargetType of accountTargetTypes.values()) {
      for (let i = year; i < year + 5; i++) {
        await runCommand(
          this.globalState,
          "exchange-offline",
          "taler-exchange-offline",
          [
            "-c",
            this.configFilename,
            "wire-fee",
            // Year
            `${i}`,
            // Wire method
            accTargetType,
            // Wire fee
            `${this.exchangeConfig.currency}:0.01`,
            // Closing fee
            `${this.exchangeConfig.currency}:0.01`,
            "upload",
          ],
        );
      }
    }
    await runCommand(
      this.globalState,
      "exchange-offline",
      "taler-exchange-offline",
      [
        "-c",
        this.configFilename,
        "global-fee",
        // year
        "now",
        // history fee
        `${this.exchangeConfig.currency}:0.01`,
        // account fee
        `${this.exchangeConfig.currency}:0.01`,
        // purse fee
        `${this.exchangeConfig.currency}:0.00`,
        // purse timeout
        "1h",
        // history expiration
        "1year",
        // free purses per account
        "5",
        "upload",
      ],
    );
  }
  async revokeDenomination(denomPubHash: string) {
    if (!this.isRunning()) {
      throw Error("exchange must be running when revoking denominations");
    }
    await runCommand(
      this.globalState,
      "exchange-offline",
      "taler-exchange-offline",
      [
        "-c",
        this.configFilename,
        "revoke-denomination",
        denomPubHash,
        "upload",
      ],
    );
  }
  async purgeSecmodKeys(): Promise {
    const cfg = Configuration.load(this.configFilename);
    const rsaKeydir = cfg
      .getPath("taler-exchange-secmod-rsa", "KEY_DIR")
      .required();
    const eddsaKeydir = cfg
      .getPath("taler-exchange-secmod-eddsa", "KEY_DIR")
      .required();
    // Be *VERY* careful when changing this, or you will accidentally delete user data.
    await sh(this.globalState, "rm-secmod-keys", `rm -rf ${rsaKeydir}/COIN_*`);
    await sh(this.globalState, "rm-secmod-keys", `rm ${eddsaKeydir}/*`);
  }
  async purgeDatabase(): Promise {
    await sh(
      this.globalState,
      "exchange-dbinit",
      `taler-exchange-dbinit -r -c "${this.configFilename}"`,
    );
  }
  private internalCreateWirewatchProc() {
    this.exchangeWirewatchProc = this.globalState.spawnService(
      "taler-exchange-wirewatch",
      [
        "-c",
        this.configFilename,
        "--longpoll-timeout=5s",
        ...this.timetravelArgArr,
      ],
      `exchange-wirewatch-${this.name}`,
    );
  }
  private internalCreateAggregatorProc() {
    this.exchangeAggregatorProc = this.globalState.spawnService(
      "taler-exchange-aggregator",
      ["-c", this.configFilename, ...this.timetravelArgArr],
      `exchange-aggregator-${this.name}`,
    );
  }
  private internalCreateTransferProc() {
    this.exchangeTransferProc = this.globalState.spawnService(
      "taler-exchange-transfer",
      ["-c", this.configFilename, ...this.timetravelArgArr],
      `exchange-transfer-${this.name}`,
    );
  }
  async dbinit() {
    await sh(
      this.globalState,
      "exchange-dbinit",
      `taler-exchange-dbinit -c "${this.configFilename}"`,
    );
  }
  async start(
    opts: { skipDbinit?: boolean; skipKeyup?: boolean } = {},
  ): Promise {
    if (this.isRunning()) {
      throw Error("exchange is already running");
    }
    const skipDbinit = opts.skipDbinit ?? false;
    if (!skipDbinit) {
      await this.dbinit();
    }
    this.helperCryptoEddsaProc = this.globalState.spawnService(
      "taler-exchange-secmod-eddsa",
      ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
      `exchange-crypto-eddsa-${this.name}`,
    );
    this.helperCryptoCsProc = this.globalState.spawnService(
      "taler-exchange-secmod-cs",
      ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
      `exchange-crypto-cs-${this.name}`,
    );
    this.helperCryptoRsaProc = this.globalState.spawnService(
      "taler-exchange-secmod-rsa",
      ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
      `exchange-crypto-rsa-${this.name}`,
    );
    this.internalCreateWirewatchProc();
    this.internalCreateTransferProc();
    this.internalCreateAggregatorProc();
    this.exchangeHttpProc = this.globalState.spawnService(
      "taler-exchange-httpd",
      ["-LINFO", "-c", this.configFilename, ...this.timetravelArgArr],
      `exchange-httpd-${this.name}`,
    );
    await this.pingUntilAvailable();
    const skipKeyup = opts.skipKeyup ?? false;
    if (!skipKeyup) {
      await this.keyup();
    } else {
      logger.info("skipping keyup");
    }
  }
  async pingUntilAvailable(): Promise {
    // We request /management/keys, since /keys can block
    // when we didn't do the key setup yet.
    const url = `http://localhost:${this.exchangeConfig.httpPort}/management/keys`;
    await pingProc(this.exchangeHttpProc, url, `exchange (${this.name})`);
  }
}
export interface MerchantConfig {
  name: string;
  currency: string;
  httpPort: number;
  database: string;
  overrideTestDir?: string;
}
export interface MerchantServiceInterface {
  makeInstanceBaseUrl(instanceName?: string): string;
  readonly port: number;
  readonly name: string;
}
/**
 * Default HTTP client handle for the integration test harness.
 */
export const harnessHttpLib = createPlatformHttpLib({
  enableThrottling: false,
});
export class MerchantService implements MerchantServiceInterface {
  static fromExistingConfig(
    gc: GlobalTestState,
    name: string,
    opts: { overridePath?: string },
  ) {
    const testDir = opts.overridePath ?? gc.testDir;
    const cfgFilename = testDir + `/merchant-${name}.conf`;
    const config = Configuration.load(cfgFilename);
    const mc: MerchantConfig = {
      currency: config.getString("taler", "currency").required(),
      database: config.getString("merchantdb-postgres", "config").required(),
      httpPort: config.getNumber("merchant", "port").required(),
      name,
    };
    return new MerchantService(gc, mc, cfgFilename);
  }
  proc: ProcessWrapper | undefined;
  constructor(
    private globalState: GlobalTestState,
    private merchantConfig: MerchantConfig,
    private configFilename: string,
  ) {}
  private currentTimetravelOffsetMs: number | undefined;
  private isRunning(): boolean {
    return !!this.proc;
  }
  setTimetravel(t: number | undefined): void {
    if (this.isRunning()) {
      throw Error("can't set time travel while the exchange is running");
    }
    this.currentTimetravelOffsetMs = t;
  }
  private get timetravelArg(): string | undefined {
    if (this.currentTimetravelOffsetMs != null) {
      // Convert to microseconds
      return `--timetravel=+${this.currentTimetravelOffsetMs * 1000}`;
    }
    return undefined;
  }
  /**
   * Return an empty array if no time travel is set,
   * and an array with the time travel command line argument
   * otherwise.
   */
  private get timetravelArgArr(): string[] {
    const tta = this.timetravelArg;
    if (tta) {
      return [tta];
    }
    return [];
  }
  get port(): number {
    return this.merchantConfig.httpPort;
  }
  get name(): string {
    return this.merchantConfig.name;
  }
  async stop(): Promise {
    const httpd = this.proc;
    if (httpd) {
      httpd.proc.kill("SIGTERM");
      await httpd.wait();
      this.proc = undefined;
    }
  }
  async dbinit() {
    await runCommand(
      this.globalState,
      "merchant-dbinit",
      "taler-merchant-dbinit",
      ["-c", this.configFilename],
    );
  }
  /**
   * Start the merchant,
   */
  async start(opts: { skipDbinit?: boolean } = {}): Promise {
    const skipSetup = opts.skipDbinit ?? false;
    if (!skipSetup) {
      await this.dbinit();
    }
    this.proc = this.globalState.spawnService(
      "taler-merchant-httpd",
      [
        "taler-merchant-httpd",
        "-LDEBUG",
        "-c",
        this.configFilename,
        ...this.timetravelArgArr,
      ],
      `merchant-${this.merchantConfig.name}`,
    );
  }
  static async create(
    gc: GlobalTestState,
    mc: MerchantConfig,
  ): Promise {
    const testDir = mc.overrideTestDir ?? gc.testDir;
    const config = new Configuration();
    config.setString("taler", "currency", mc.currency);
    const cfgFilename = testDir + `/merchant-${mc.name}.conf`;
    setTalerPaths(config, testDir + "/talerhome");
    config.setString("merchant", "serve", "tcp");
    config.setString("merchant", "port", `${mc.httpPort}`);
    config.setString(
      "merchant",
      "keyfile",
      "${TALER_DATA_HOME}/merchant/merchant.priv",
    );
    config.setString("merchantdb-postgres", "config", mc.database);
    config.write(cfgFilename, { excludeDefaults: true });
    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, { excludeDefaults: true });
  }
  async addDefaultInstance(): Promise {
    return await this.addInstanceWithWireAccount({
      id: "default",
      name: "Default Instance",
      paytoUris: [generateRandomPayto("merchant-default")],
      auth: {
        method: "external",
      },
    });
  }
  /**
   * Add an instance together with a wire account.
   */
  async addInstanceWithWireAccount(
    instanceConfig: PartialMerchantInstanceConfig,
  ): Promise {
    if (!this.proc) {
      throw Error("merchant must be running to add instance");
    }
    logger.info(`adding instance '${instanceConfig.id}'`);
    const url = `http://localhost:${this.merchantConfig.httpPort}/management/instances`;
    const auth = instanceConfig.auth ?? { method: "external" };
    const body: MerchantInstanceConfig = {
      auth,
      accounts: instanceConfig.paytoUris.map((x) => ({
        payto_uri: x,
      })),
      id: instanceConfig.id,
      name: instanceConfig.name,
      address: instanceConfig.address ?? {},
      jurisdiction: instanceConfig.jurisdiction ?? {},
      // FIXME: In some tests, we might want to make this configurable
      use_stefan: true,
      default_wire_transfer_delay:
        instanceConfig.defaultWireTransferDelay ??
        Duration.toTalerProtocolDuration(
          Duration.fromSpec({
            days: 1,
          }),
        ),
      default_pay_delay:
        instanceConfig.defaultPayDelay ??
        Duration.toTalerProtocolDuration(Duration.getForever()),
    };
    const resp = await harnessHttpLib.fetch(url, { method: "POST", body });
    await expectSuccessResponseOrThrow(resp);
    const accountCreateUrl = `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceConfig.id}/private/accounts`;
    for (const paytoUri of instanceConfig.paytoUris) {
      const accountReq: AccountAddDetails = {
        payto_uri: paytoUri,
      };
      const acctResp = await harnessHttpLib.fetch(accountCreateUrl, {
        method: "POST",
        body: accountReq,
      });
      await expectSuccessResponseOrThrow(acctResp);
    }
  }
  makeInstanceBaseUrl(instanceName?: string): string {
    if (instanceName === undefined || instanceName === "default") {
      return `http://localhost:${this.merchantConfig.httpPort}/`;
    } else {
      return `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/`;
    }
  }
  async pingUntilAvailable(): Promise {
    const url = `http://localhost:${this.merchantConfig.httpPort}/config`;
    await pingProc(this.proc, url, `merchant (${this.merchantConfig.name})`);
  }
}
type TestStatus = "pass" | "fail" | "skip";
export interface TestRunResult {
  /**
   * Name of the test.
   */
  name: string;
  /**
   * How long did the test run?
   */
  timeSec: number;
  status: TestStatus;
  reason?: string;
}
export async function runTestWithState(
  gc: GlobalTestState,
  testMain: (t: GlobalTestState) => Promise,
  testName: string,
  linger: boolean = false,
): Promise {
  const startMs = new Date().getTime();
  const p = openPromise();
  let status: TestStatus;
  const handleSignal = (s: string) => {
    logger.warn(
      `**** received fatal process event (${s}), terminating test ${testName}`,
    );
    gc.shutdownSync();
    process.exit(1);
  };
  process.on("SIGINT", handleSignal);
  process.on("SIGTERM", handleSignal);
  process.on("unhandledRejection", (reason: unknown, promise: any) => {
    logger.warn(
      `**** received unhandled rejection (${reason}), terminating test ${testName}`,
    );
    logger.warn(`reason type: ${typeof reason}`);
    gc.shutdownSync();
    process.exit(1);
  });
  process.on("uncaughtException", (error, origin) => {
    logger.warn(
      `**** received uncaught exception (${error}), terminating test ${testName}`,
    );
    console.warn("stack", error.stack);
    gc.shutdownSync();
    process.exit(1);
  });
  try {
    logger.info("running test in directory", gc.testDir);
    await Promise.race([testMain(gc), p.promise]);
    logger.info("completed test in directory", gc.testDir);
    status = "pass";
    if (linger) {
      const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout,
        terminal: true,
      });
      await new Promise((resolve, reject) => {
        rl.question("Press enter to shut down test.", () => {
          logger.error("Requested shutdown");
          resolve();
        });
      });
      rl.close();
    }
  } catch (e) {
    if (e instanceof CommandError) {
      console.error("FATAL: test failed for", e.logName);
      const errorLog = fs.readFileSync(
        path.join(gc.testDir, `${e.logName}-stderr.log`),
      );
      console.error(`${e.message}: "${e.command}"`);
      console.error(errorLog.toString());
      console.error(e);
    } else if (e instanceof TalerError) {
      console.error(
        "FATAL: test failed",
        e.message,
        `error detail: ${j2s(e.errorDetail)}`,
      );
      console.error(e.stack);
    } else {
      console.error("FATAL: test failed with exception", e);
    }
    status = "fail";
  } finally {
    await gc.shutdown();
  }
  const afterMs = new Date().getTime();
  return {
    name: testName,
    timeSec: (afterMs - startMs) / 1000,
    status,
  };
}
function shellWrap(s: string) {
  return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'";
}
export interface WalletCliOpts {
  cryptoWorkerType?: "sync" | "node-worker-thread";
}
function tryUnixConnect(socketPath: string): Promise {
  return new Promise((resolve, reject) => {
    const client = net.createConnection(socketPath);
    client.on("error", (e) => {
      reject(e);
    });
    client.on("connect", () => {
      client.end();
      resolve();
    });
  });
}
export interface WalletServiceOptions {
  useInMemoryDb?: boolean;
  name: string;
}
export class WalletService {
  walletProc: ProcessWrapper | undefined;
  constructor(
    private globalState: GlobalTestState,
    private opts: WalletServiceOptions,
  ) {}
  get socketPath() {
    const unixPath = path.join(
      this.globalState.testDir,
      `${this.opts.name}.sock`,
    );
    return unixPath;
  }
  get dbPath() {
    return path.join(
      this.globalState.testDir,
      `walletdb-${this.opts.name}.json`,
    );
  }
  async stop(): Promise {
    if (this.walletProc) {
      this.walletProc.proc.kill("SIGTERM");
      await this.walletProc.wait();
    }
  }
  async start(): Promise {
    let dbPath: string;
    if (this.opts.useInMemoryDb) {
      dbPath = ":memory:";
    } else {
      dbPath = path.join(
        this.globalState.testDir,
        `walletdb-${this.opts.name}.json`,
      );
    }
    const unixPath = this.socketPath;
    this.walletProc = this.globalState.spawnService(
      "taler-wallet-cli",
      [
        "--wallet-db",
        dbPath,
        "-LTRACE", // FIXME: Make this configurable?
        "--no-throttle", // FIXME: Optionally do throttling for some tests?
        "advanced",
        "serve",
        "--unix-path",
        unixPath,
      ],
      `wallet-${this.opts.name}`,
    );
    logger.info(
      `hint: connect to wallet using taler-wallet-cli --wallet-connection=${unixPath}`,
    );
  }
  async pingUntilAvailable(): Promise {
    let nextDelay = backoffStart();
    while (1) {
      try {
        await tryUnixConnect(this.socketPath);
      } catch (e) {
        logger.info(`wallet connection attempt failed: ${e}`);
        logger.info(`waiting on wallet for ${nextDelay}ms`);
        await delayMs(nextDelay);
        nextDelay = backoffIncrement(nextDelay);
        continue;
      }
      logger.info("connection to wallet-core succeeded");
      break;
    }
  }
}
export interface WalletClientArgs {
  unixPath: string;
  onNotification?(n: WalletNotification): void;
}
export type CancelFn = () => void;
export type NotificationHandler = (n: WalletNotification) => void;
/**
 * Convenience wrapper around a (remote) wallet handle.
 */
export class WalletClient {
  remoteWallet: RemoteWallet | undefined = undefined;
  private waiter: WalletNotificationWaiter = makeNotificationWaiter();
  notificationHandlers: NotificationHandler[] = [];
  addNotificationListener(f: NotificationHandler): CancelFn {
    this.notificationHandlers.push(f);
    return () => {
      const idx = this.notificationHandlers.indexOf(f);
      if (idx >= 0) {
        this.notificationHandlers.splice(idx, 1);
      }
    };
  }
  async call(
    operation: Op,
    payload: WalletCoreRequestType,
  ): Promise> {
    if (!this.remoteWallet) {
      throw Error("wallet not connected");
    }
    const client = getClientFromRemoteWallet(this.remoteWallet);
    return client.call(operation, payload);
  }
  constructor(private args: WalletClientArgs) {}
  async connect(): Promise {
    const waiter = this.waiter;
    const walletClient = this;
    const w = await createRemoteWallet({
      socketFilename: this.args.unixPath,
      notificationHandler(n) {
        if (walletClient.args.onNotification) {
          walletClient.args.onNotification(n);
        }
        waiter.notify(n);
        for (const h of walletClient.notificationHandlers) {
          h(n);
        }
      },
    });
    this.remoteWallet = w;
    this.waiter.waitForNotificationCond;
  }
  get client() {
    if (!this.remoteWallet) {
      throw Error("wallet not connected");
    }
    return getClientFromRemoteWallet(this.remoteWallet);
  }
  waitForNotificationCond(
    cond: (n: WalletNotification) => T | undefined | false,
  ): Promise {
    return this.waiter.waitForNotificationCond(cond);
  }
}
export class WalletCli {
  private currentTimetravel: Duration | undefined;
  private _client: WalletCoreApiClient;
  setTimetravel(d: Duration | undefined) {
    this.currentTimetravel = d;
  }
  private get timetravelArg(): string | undefined {
    if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
      // Convert to microseconds
      return `--timetravel=${this.currentTimetravel.d_ms * 1000}`;
    }
    return undefined;
  }
  constructor(
    private globalTestState: GlobalTestState,
    private name: string = "default",
    cliOpts: WalletCliOpts = {},
  ) {
    const self = this;
    this._client = {
      async call(op: any, payload: any): Promise {
        logger.info(
          `calling wallet with timetravel arg ${j2s(self.timetravelArg)}`,
        );
        const cryptoWorkerArg = cliOpts.cryptoWorkerType
          ? `--crypto-worker=${cliOpts.cryptoWorkerType}`
          : "";
        const logName = `wallet-${self.name}`;
        const command = `taler-wallet-cli ${
          self.timetravelArg ?? ""
        } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${
          self.dbfile
        }' api '${op}' ${shellWrap(JSON.stringify(payload))}`;
        const resp = await sh(self.globalTestState, logName, command);
        logger.info("--- wallet core response ---");
        logger.info(resp);
        logger.info("--- end of response ---");
        let ar: CoreApiResponse;
        try {
          ar = JSON.parse(resp);
        } catch (e) {
          throw new CommandError(
            "wallet CLI did not return a proper JSON response",
            logName,
            command,
            [],
            {},
            null,
          );
        }
        if (ar.type === "error") {
          throw TalerError.fromUncheckedDetail(ar.error);
        }
        return ar.result;
      },
    };
  }
  get dbfile(): string {
    return this.globalTestState.testDir + `/walletdb-${this.name}.json`;
  }
  deleteDatabase() {
    fs.unlinkSync(this.dbfile);
  }
  private get timetravelArgArr(): string[] {
    const tta = this.timetravelArg;
    if (tta) {
      return [tta];
    }
    return [];
  }
  get client(): WalletCoreApiClient {
    return this._client;
  }
  async runUntilDone(args: { maxRetries?: number } = {}): Promise {
    await runCommand(
      this.globalTestState,
      `wallet-${this.name}`,
      "taler-wallet-cli",
      [
        "--no-throttle",
        ...this.timetravelArgArr,
        "-LTRACE",
        "--skip-defaults",
        "--wallet-db",
        this.dbfile,
        "run-until-done",
        ...(args.maxRetries ? ["--max-retries", `${args.maxRetries}`] : []),
      ],
    );
  }
  async runPending(): Promise {
    await runCommand(
      this.globalTestState,
      `wallet-${this.name}`,
      "taler-wallet-cli",
      [
        "--no-throttle",
        "--skip-defaults",
        "-LTRACE",
        ...this.timetravelArgArr,
        "--wallet-db",
        this.dbfile,
        "advanced",
        "run-pending",
      ],
    );
  }
}
export function generateRandomTestIban(salt: string | null = null): string {
  function getBban(salt: string | null): string {
    if (!salt) return Math.random().toString().substring(2, 6);
    let hashed = hash(stringToBytes(salt));
    let ret = "";
    for (let i = 0; i < hashed.length; i++) {
      ret += hashed[i].toString();
    }
    return ret.substring(0, 4);
  }
  let cc_no_check = "131400"; // == DE00
  let bban = getBban(salt);
  let check_digits = (
    98 -
    (Number.parseInt(`${bban}${cc_no_check}`) % 97)
  ).toString();
  if (check_digits.length == 1) {
    check_digits = `0${check_digits}`;
  }
  return `DE${check_digits}${bban}`;
}
export function getWireMethodForTest(): string {
  if (useLibeufinBank) return "iban";
  return "x-taler-bank";
}
/**
 * Generate a payto address, whose authority depends
 * on whether the banking is served by euFin or Pybank.
 */
export function generateRandomPayto(label: string): string {
  if (useLibeufinBank)
    return `payto://iban/SANDBOXX/${generateRandomTestIban(
      label,
    )}?receiver-name=${label}`;
  return `payto://x-taler-bank/localhost/${label}`;
}
function waitMs(ms: number): Promise {
  return new Promise((resolve) => setTimeout(resolve, ms));
}