taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 7ae477b1f744339456c34ab1df365a4aa56f954e
parent 3106f9c0ffed981bbd3501943e3508659643bd81
Author: Florian Dold <dold@taler.net>
Date:   Mon, 24 Aug 2026 02:30:45 +0200

taler-harness: cover Bank WebUI workflows in Chromium

Diffstat:
Apackages/taler-harness/src/harness/bank-webui-browser.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/harness/environments.ts | 5+++++
Mpackages/taler-harness/src/harness/harness.ts | 11+++--------
Mpackages/taler-harness/src/harness/tan-helper.ts | 37+++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/harness/webui-server.ts | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mpackages/taler-harness/src/index.ts | 6+++---
Apackages/taler-harness/src/integrationtests/test-libeufin-bank-webui.ts | 539+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 6++++++
8 files changed, 824 insertions(+), 64 deletions(-)

diff --git a/packages/taler-harness/src/harness/bank-webui-browser.ts b/packages/taler-harness/src/harness/bank-webui-browser.ts @@ -0,0 +1,119 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { Browser, Page } from "playwright-core"; +import { findBrowserBinary, installNativeUrl } from "../stagefright/stage.js"; + +export type BankWebuiBrowserEngine = "chromium" | "firefox"; + +export interface BankWebuiBrowserOptions { + browserType?: BankWebuiBrowserEngine; + screenshotDir?: string; +} + +export interface BankWebuiBrowser { + browser: Browser; + page: Page; + saveScreenshot: (name: string) => Promise<void>; + close: () => Promise<void>; +} + +async function loadPlaywright(): Promise<typeof import("playwright-core")> { + try { + return await import("playwright-core"); + } catch (cause) { + throw Error( + `unable to load playwright-core, run 'pnpm install' in taler-harness (${cause})`, + ); + } +} + +/** Remove authentication material before browser diagnostics reach CI logs. */ +export function sanitizeBankWebuiLog(text: string): string { + return text + .replace(/(authorization\s*[:=]\s*bearer\s+)[^\s,;]+/gi, "$1[redacted]") + .replace(/(access[_ -]?token\s*[:=]\s*)[^\s,;]+/gi, "$1[redacted]") + .replace(/(password\s*[:=]\s*)[^\s,;]+/gi, "$1[redacted]") + .replace(/(taler-challenge-ids?\s*[:=]\s*)[^\s;]+/gi, "$1[redacted]") + .replace(/secret-token:[^\s,;"']+/gi, "secret-token:[redacted]") + .replace(/([?&](?:access_)?token=)[^&\s]+/gi, "$1[redacted]") + .replace(/\b(?:T-)?[0-9]{4}-?[0-9]{4}\b/g, "[redacted-tan]") + .replace( + /taler(?:\+https?)?:\/\/withdraw\/[^\s"']+/gi, + "[redacted-withdraw-uri]", + ); +} + +/** + * Browser-engine-neutral launcher for local Bank WebUI integration tests. + * Chromium is the required CI engine; callers may select Firefox locally. + */ +export async function launchBankWebuiBrowser( + options: BankWebuiBrowserOptions = {}, +): Promise<BankWebuiBrowser> { + const restoreUrl = installNativeUrl(); + try { + const playwright = await loadPlaywright(); + const browserType = options.browserType ?? "chromium"; + const browser = await playwright[browserType].launch({ + headless: true, + executablePath: + browserType === "chromium" ? findBrowserBinary() : undefined, + }); + const page = await browser.newPage({ + viewport: { width: 1280, height: 1024 }, + }); + page.setDefaultTimeout(15_000); + + const screenshotsEnabled = ["1", "true", "yes"].includes( + process.env.HARNESS_SCREENSHOTS?.toLowerCase() ?? "", + ); + const screenshotDir = + screenshotsEnabled && options.screenshotDir + ? options.screenshotDir + : undefined; + if (screenshotDir) fs.mkdirSync(screenshotDir, { recursive: true }); + + return { + browser, + page, + saveScreenshot: async (name: string) => { + if (!screenshotDir) return; + const slug = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || "bank-webui"; + await page.screenshot({ + path: path.join(screenshotDir, `${slug}.png`), + fullPage: true, + }); + }, + close: async () => { + try { + await browser.close(); + } finally { + restoreUrl(); + } + }, + }; + } catch (cause) { + restoreUrl(); + throw cause; + } +} diff --git a/packages/taler-harness/src/harness/environments.ts b/packages/taler-harness/src/harness/environments.ts @@ -184,6 +184,8 @@ export interface EnvOptions { walletTestObservability?: boolean; + walletLogLevel?: string; + accountRestrictions?: HarnessAccountRestriction[]; /** @@ -567,6 +569,7 @@ export async function createSimpleTestkudosEnvironmentV3( persistent: true, emitObservabilityEvents: !!opts.walletTestObservability, config: opts.walletConfig, + logLevel: opts.walletLogLevel, }, ); @@ -616,6 +619,7 @@ export interface CreateWalletArgs { * Environment variables to add to the ones the wallet process inherits. */ extraEnv?: Record<string, string>; + logLevel?: string; } export async function createWalletDaemonWithClient( @@ -627,6 +631,7 @@ export async function createWalletDaemonWithClient( useInMemoryDb: !args.persistent, overrideDbPath: args.overrideDbPath, extraEnv: args.extraEnv, + logLevel: args.logLevel, }); await walletService.start(); await walletService.pingUntilAvailable(); diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -2718,7 +2718,6 @@ export class MerchantService implements MerchantServiceInterface { headers["Authorization"] = `Bearer ${adminAccessToken}`; } - console.log("CREATING", body, headers); const resp = await harnessHttpLib.fetch(url, { method: "POST", body, @@ -2736,12 +2735,7 @@ export class MerchantService implements MerchantServiceInterface { MERCHANT_DEFAULT_LOGIN_SCOPE, ), ); - console.log( - "CREATED", - instanceConfig.id, - auth.password, - MERCHANT_DEFAULT_LOGIN_SCOPE, - ); + logger.info(`created merchant instance '${instanceConfig.id}'`); for (const paytoUri of instanceConfig.paytoUris) { const accountReq: TalerMerchantApi.AccountAddDetails = { @@ -2926,6 +2920,7 @@ export interface WalletServiceOptions { * Environment variables to add to the ones the wallet process inherits. */ extraEnv?: Record<string, string>; + logLevel?: string; } /** @@ -2980,7 +2975,7 @@ export class WalletService { [ "--wallet-db", this.dbPath, - "-LTRACE", // FIXME: Make this configurable? + `-L${this.opts.logLevel ?? "TRACE"}`, "--no-throttle", // FIXME: Optionally do throttling for some tests? "advanced", "serve", diff --git a/packages/taler-harness/src/harness/tan-helper.ts b/packages/taler-harness/src/harness/tan-helper.ts @@ -15,6 +15,7 @@ */ import * as fs from "node:fs"; +import * as path from "node:path"; import { ChallengeResponse, @@ -164,3 +165,39 @@ export function configureTestMerchantMfa( } cfg.setString("merchant", "MANDATORY_TAN_CHANNELS", channels.join(" ")); } + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +/** + * Configure LibEuFin Bank TAN delivery through the harness' JSON dump helper. + * The wrappers only pass the destination address and message to that helper; + * neither is printed by the test itself. + */ +export function configureTestBankMfa( + t: GlobalTestState, + cfg: Configuration, + email: string, + sms: string, +): TestMfaChannelConfigEmailSms { + const channels = makeMfaConfigEmailSms(t, email, sms); + const wrappers: Array<[TanChannel, string, string]> = [ + [TanChannel.EMAIL, channels.email.path, "tan-email-helper.sh"], + [TanChannel.SMS, channels.sms.path, "tan-sms-helper.sh"], + ]; + const runningScript = process.argv[1]; + const harnessExecutable = runningScript.endsWith("taler-harness-bundled.cjs") + ? path.resolve(path.dirname(runningScript), "../bin/taler-harness.mjs") + : runningScript; + for (const [channel, outputPath, filename] of wrappers) { + const wrapperPath = path.join(t.testDir, filename); + fs.writeFileSync( + wrapperPath, + `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(harnessExecutable)} helper-2fa-dump ${shellQuote(outputPath)} "$1"\n`, + { mode: 0o700 }, + ); + cfg.setString("libeufin-bank", `TAN_${channel.toUpperCase()}`, wrapperPath); + } + return channels; +} diff --git a/packages/taler-harness/src/harness/webui-server.ts b/packages/taler-harness/src/harness/webui-server.ts @@ -32,66 +32,71 @@ export interface WebuiServerOptions { experimental?: boolean; } -/** - * Launch an HTTP static file server serving compiled merchant-webui dist assets, - * dynamically serving /webui-config.json targeting merchantBaseUrl. - */ -export async function startStaticServerMerchantWebui( - merchantBaseUrl: string, - options: WebuiServerOptions = {}, -): Promise<WebuiServer> { +function findWorkspaceRoot(packageName: string): string { let rootDir = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../..", ); - if (!fs.existsSync(path.join(rootDir, "packages/taler-merchant-webui"))) { - let curr = process.cwd(); - while (curr !== path.parse(curr).root) { - if (fs.existsSync(path.join(curr, "packages/taler-merchant-webui"))) { - rootDir = curr; - break; - } - curr = path.dirname(curr); + if (fs.existsSync(path.join(rootDir, `packages/${packageName}`))) { + return rootDir; + } + let curr = process.cwd(); + while (curr !== path.parse(curr).root) { + if (fs.existsSync(path.join(curr, `packages/${packageName}`))) { + return curr; } + curr = path.dirname(curr); } - let distDir = path.join(rootDir, "packages/taler-merchant-webui/dist/prod"); + throw new Error(`could not find packages/${packageName} in a workspace`); +} + +function contentTypeFor(filePath: string): string { + if (filePath.endsWith(".js")) return "application/javascript"; + if (filePath.endsWith(".css")) return "text/css"; + if (filePath.endsWith(".json")) return "application/json"; + if (filePath.endsWith(".png")) return "image/png"; + if (filePath.endsWith(".svg")) return "image/svg+xml"; + return "text/html"; +} + +async function startStaticWebuiServer(args: { + packageName: string; + packageFilter: string; + dynamicFiles: Record<string, unknown | ((serverBaseUrl: string) => unknown)>; + proxyBaseUrl?: string; +}): Promise<WebuiServer> { + const rootDir = findWorkspaceRoot(args.packageName); + let distDir = path.join(rootDir, `packages/${args.packageName}/dist/prod`); if (!fs.existsSync(distDir)) { - distDir = path.join(rootDir, "packages/taler-merchant-webui/dist/dev"); + logger.info(`building ${args.packageName} package...`); + execFileSync("pnpm", ["--filter", args.packageFilter, "build"], { + cwd: rootDir, + stdio: "inherit", + }); } - if (!fs.existsSync(distDir)) { - logger.info("building merchant-webui package..."); - execFileSync( - "pnpm", - ["--filter", "@gnu-taler/taler-merchant-webui", "compile"], - { - cwd: rootDir, - stdio: "inherit", - }, - ); - distDir = path.join(rootDir, "packages/taler-merchant-webui/dist/prod"); + throw new Error(`${args.packageName} build did not create ${distDir}`); } logger.info(`serving local webui static files from ${distDir}`); - return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { const reqPath = req.url ? new URL(req.url, "http://127.0.0.1").pathname : "/"; - - // Dynamically inject webui-config.json to target merchantBaseUrl - if (reqPath === "/webui-config.json") { + const dynamicBody = args.dynamicFiles[reqPath]; + if (dynamicBody !== undefined) { + const serverBaseUrl = `http://${req.headers.host ?? "127.0.0.1"}/`; res.writeHead(200, { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-store", }); res.end( - JSON.stringify({ - experimental: Boolean(options.experimental), - merchant_base_url: merchantBaseUrl, - merchant_base_url_configurable: true, - }), + JSON.stringify( + typeof dynamicBody === "function" + ? dynamicBody(serverBaseUrl) + : dynamicBody, + ), ); return; } @@ -101,24 +106,39 @@ export async function startStaticServerMerchantWebui( reqPath === "/" ? "index.html" : reqPath, ); if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { + if (args.proxyBaseUrl) { + const target = new URL(req.url ?? "/", args.proxyBaseUrl); + const proxy = http.request( + target, + { + method: req.method, + headers: { ...req.headers, host: target.host }, + }, + (proxyResponse) => { + res.writeHead( + proxyResponse.statusCode ?? 502, + proxyResponse.headers, + ); + proxyResponse.pipe(res); + }, + ); + proxy.on("error", () => { + if (!res.headersSent) res.writeHead(502); + res.end("Bank backend unavailable"); + }); + req.pipe(proxy); + return; + } filePath = path.join(distDir, "index.html"); } - - let contentType = "text/html"; - if (filePath.endsWith(".js")) contentType = "application/javascript"; - else if (filePath.endsWith(".css")) contentType = "text/css"; - else if (filePath.endsWith(".json")) contentType = "application/json"; - else if (filePath.endsWith(".png")) contentType = "image/png"; - else if (filePath.endsWith(".svg")) contentType = "image/svg+xml"; - try { const data = fs.readFileSync(filePath); res.writeHead(200, { - "Content-Type": contentType, - "Access-Control-Allow-Origin": "*", + "Content-Type": contentTypeFor(filePath), + "Cache-Control": "no-store", }); res.end(data); - } catch (e) { + } catch (_cause) { res.writeHead(404); res.end("Not Found"); } @@ -126,13 +146,52 @@ export async function startStaticServerMerchantWebui( server.listen(0, "127.0.0.1", () => { const addr = server.address() as { port: number }; - const url = `http://127.0.0.1:${addr.port}/`; resolve({ - url, + url: `http://127.0.0.1:${addr.port}/`, close: () => new Promise((res) => server.close(() => res())), }); }); - server.on("error", reject); }); } + +/** Serve a production Bank WebUI build against an explicitly selected bank. */ +export async function startStaticServerBankWebui( + backendBaseUrl: string, +): Promise<WebuiServer> { + return startStaticWebuiServer({ + packageName: "libeufin-bank-webui", + packageFilter: "@gnu-taler/libeufin-bank-webui", + proxyBaseUrl: backendBaseUrl, + dynamicFiles: { + "/settings.json": (serverBaseUrl: string) => ({ + backendBaseURL: serverBaseUrl, + allowRandomAccountCreation: false, + showDemoDescription: false, + topNavSites: {}, + defaultSuggestedAmount: 10, + }), + }, + }); +} + +/** + * Launch an HTTP static file server serving compiled merchant-webui dist assets, + * dynamically serving /webui-config.json targeting merchantBaseUrl. + */ +export async function startStaticServerMerchantWebui( + merchantBaseUrl: string, + options: WebuiServerOptions = {}, +): Promise<WebuiServer> { + return startStaticWebuiServer({ + packageName: "taler-merchant-webui", + packageFilter: "@gnu-taler/taler-merchant-webui", + dynamicFiles: { + "/webui-config.json": { + experimental: Boolean(options.experimental), + merchant_base_url: merchantBaseUrl, + merchant_base_url_configurable: true, + }, + }, + }); +} diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -2123,12 +2123,12 @@ talerHarnessCli logger.info( `started 2fa dump helper with target path ${path} and address ${address}`, ); - logger.info(`got input: ${input}`); - const searchRes = /[0-9-]+/.exec(input); + logger.info("received 2fa helper input"); + const searchRes = /(?:T-)?([0-9]{8}|[0-9]{4}-[0-9]{4})/.exec(input); if (!searchRes) { throw Error("could not find code in message"); } - const code = searchRes[0]; + const code = searchRes[1].replace("-", ""); fs.writeFileSync( path, JSON.stringify({ diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-bank-webui.ts b/packages/taler-harness/src/integrationtests/test-libeufin-bank-webui.ts @@ -0,0 +1,539 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AmountString, + HttpStatusCode, + TalerBankConversionHttpClient, + TalerCoreBankHttpClient, + TalerUriAction, + TalerUris, + TanChannel, + TransactionMajorState, + TransactionMinorState, + succeedOrThrow, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation as WalletOperation } from "@gnu-taler/taler-wallet-core"; +import fs from "node:fs"; +import path from "node:path"; +import type { Page } from "playwright-core"; +import { + launchBankWebuiBrowser, + sanitizeBankWebuiLog, + type BankWebuiBrowser, +} from "../harness/bank-webui-browser.js"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { + GlobalTestState, + LibeufinBankService, + LibeufinNexusService, + WalletClient, + getTestHarnessPaytoForLabel, + setupDb, +} from "../harness/harness.js"; +import { + configureTestBankMfa, + type TestMfaChannelConfigEmailSms, + wait2FaCode, +} from "../harness/tan-helper.js"; +import { startStaticServerBankWebui } from "../harness/webui-server.js"; + +const TEST_TIMEOUT_MS = 300_000; +const USERNAME = "webui-user"; +const PASSWORD = "webui-password"; + +function removeDeliveredCodes(config: TestMfaChannelConfigEmailSms): void { + fs.rmSync(config.email.path, { force: true }); + fs.rmSync(config.sms.path, { force: true }); +} + +function attachSafeBrowserDiagnostics(page: Page): void { + page.on("console", (message) => { + if (message.type() === "error") { + console.log( + `BANK WEBUI [error]: ${sanitizeBankWebuiLog(message.text())}`, + ); + } + }); + page.on("pageerror", (cause) => { + console.log( + `BANK WEBUI [pageerror]: ${sanitizeBankWebuiLog(cause.message)}`, + ); + }); +} + +async function login( + page: Page, + webuiUrl: string, + username: string, + password: string, +): Promise<void> { + await page.goto(`${webuiUrl}#/login`); + await page.getByLabel("Username", { exact: true }).fill(username); + await page.getByLabel("Password", { exact: true }).fill(password); + await page.getByRole("button", { name: "Log in", exact: true }).click(); +} + +async function solveBrowserChallenge( + t: GlobalTestState, + page: Page, + config: TestMfaChannelConfigEmailSms, + channel: TanChannel, + expectedAddress: string, +): Promise<void> { + const dialog = page.locator("dialog[open]"); + await dialog + .getByText("Multi-factor authentication required", { exact: true }) + .waitFor({ state: "visible" }); + const card = dialog + .locator("div.rounded-xl.border") + .filter({ hasText: channel === TanChannel.EMAIL ? /email/i : /phone/i }) + .first(); + await card + .getByRole("button", { name: "Send me a message", exact: true }) + .click(); + const delivery = await wait2FaCode(config[channel].path); + t.assertDeepEqual(delivery.address, expectedAddress); + fs.rmSync(config[channel].path, { force: true }); + await dialog.getByLabel("Code", { exact: true }).fill(delivery.code); + await dialog.getByRole("button", { name: "Verify", exact: true }).click(); +} + +async function solveAutomaticallySentBrowserChallenge( + t: GlobalTestState, + page: Page, + config: TestMfaChannelConfigEmailSms, + channel: TanChannel, + expectedAddress: string, +): Promise<void> { + const delivery = await wait2FaCode(config[channel].path); + t.assertDeepEqual(delivery.address, expectedAddress); + fs.rmSync(config[channel].path, { force: true }); + const dialog = page.locator("dialog[open]"); + await dialog.getByLabel("Code", { exact: true }).fill(delivery.code); + await dialog.getByRole("button", { name: "Verify", exact: true }).click(); +} + +async function waitForDialogToClose(page: Page): Promise<void> { + await page.locator("dialog[open]").waitFor({ state: "hidden" }); +} + +async function waitForNextMfaDelivery( + config: TestMfaChannelConfigEmailSms, +): Promise<{ + channel: TanChannel.EMAIL | TanChannel.SMS; + delivery: Awaited<ReturnType<typeof wait2FaCode>>; +}> { + for (let attempt = 0; attempt < 150; attempt++) { + if (fs.existsSync(config.email.path)) { + return { + channel: TanChannel.EMAIL, + delivery: await wait2FaCode(config.email.path), + }; + } + if (fs.existsSync(config.sms.path)) { + return { + channel: TanChannel.SMS, + delivery: await wait2FaCode(config.sms.path), + }; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw Error("timed out waiting for the next MFA delivery"); +} + +async function finishAndChallengeSequence( + t: GlobalTestState, + page: Page, + config: TestMfaChannelConfigEmailSms, + expectedEmail: string, + expectedSms: string, +): Promise<void> { + const dialog = page.locator("dialog[open]"); + const complete = dialog.getByRole("button", { + name: "Complete", + exact: true, + }); + for (let remaining = 3; remaining > 0; remaining--) { + await page.waitForTimeout(100); + if (!(await dialog.isVisible())) return; + if (await complete.isEnabled()) { + await complete.click(); + return; + } + const next = await waitForNextMfaDelivery(config); + t.assertDeepEqual( + next.delivery.address, + next.channel === TanChannel.EMAIL ? expectedEmail : expectedSms, + ); + fs.rmSync(config[next.channel].path, { force: true }); + await dialog.getByLabel("Code", { exact: true }).fill(next.delivery.code); + await dialog.getByRole("button", { name: "Verify", exact: true }).click(); + } + throw Error("AND challenge sequence did not become complete"); +} + +/** + * Bank WebUI authentication, account reconfiguration, protected transfer and + * anonymous public-history contract against a real LibEuFin Bank process. + */ +export async function runLibeufinBankWebuiCoreTest(t: GlobalTestState) { + const oldEmail = "bank-webui-old@example.test"; + const oldPhone = "+15550102001"; + const newEmail = "bank-webui-new@example.test"; + const newPhone = "+15550102002"; + const publicAccount = "webui-public"; + const privateAccount = "webui-private"; + const db = await setupDb(t); + const bank = await LibeufinBankService.create(t, { + currency: "TESTKUDOS", + httpPort: 8082, + database: db.connStr, + allowRegistrations: true, + }); + let mfa!: TestMfaChannelConfigEmailSms; + bank.changeConfig((config) => { + mfa = configureTestBankMfa(t, config, oldEmail, oldPhone); + }); + await bank.start(); + + const api = new TalerCoreBankHttpClient(bank.corebankApiBaseUrl); + const adminAuth = bank.getAdminAuth(); + succeedOrThrow( + await api.createAccount(adminAuth, { + username: USERNAME, + password: PASSWORD, + name: "Bank WebUI User", + contact_data: { email: oldEmail, phone: oldPhone }, + tan_channels: [TanChannel.EMAIL, TanChannel.SMS], + payto_uri: getTestHarnessPaytoForLabel(USERNAME), + }), + ); + succeedOrThrow( + await api.createAccount(adminAuth, { + username: publicAccount, + password: "public-password", + name: "Public Bank WebUI History", + is_public: true, + payto_uri: getTestHarnessPaytoForLabel(publicAccount), + }), + ); + succeedOrThrow( + await api.createAccount(adminAuth, { + username: privateAccount, + password: "private-password", + name: "Private Bank WebUI History", + is_public: false, + payto_uri: getTestHarnessPaytoForLabel(privateAccount), + }), + ); + + const webui = await startStaticServerBankWebui(bank.corebankApiBaseUrl); + let browser: BankWebuiBrowser | undefined; + try { + browser = await launchBankWebuiBrowser({ + browserType: "chromium", + screenshotDir: path.join(t.testDir, "bank-webui-core-screenshots"), + }); + const page = browser.page; + attachSafeBrowserDiagnostics(page); + + removeDeliveredCodes(mfa); + await login(page, webui.url, USERNAME, PASSWORD); + // An OR challenge must allow an explicit channel choice. + await solveBrowserChallenge(t, page, mfa, TanChannel.EMAIL, oldEmail); + await waitForDialogToClose(page); + // Session state changes in-place, so explicitly enter the private route + // after the login form disappears instead of relying on a redirect. + await page.getByRole("button", { name: "Log in", exact: true }).waitFor({ + state: "hidden", + }); + await page.goto(`${webui.url}#/account`); + await browser.saveScreenshot("signed-in"); + + // Changing both contacts first proves an existing OR channel, then proves + // both new contacts with a sequential AND challenge. LibEuFin orders the + // newly changed phone first; the e-mail transmission must wait for it. + removeDeliveredCodes(mfa); + await page.goto(`${webui.url}#/my-profile`); + await page.getByLabel("Email", { exact: true }).fill(newEmail); + await page.getByLabel("Phone", { exact: true }).fill(newPhone); + await page.getByRole("button", { name: "Update", exact: true }).click(); + await solveBrowserChallenge(t, page, mfa, TanChannel.SMS, oldPhone); + await page + .locator("dialog[open]") + .getByText(/All the next challenges need to be completed/) + .waitFor({ state: "visible" }); + + await wait2FaCode(mfa.sms.path); + t.assertTrue( + !fs.existsSync(mfa.email.path), + "the next AND challenge was transmitted before the first was confirmed", + ); + await solveAutomaticallySentBrowserChallenge( + t, + page, + mfa, + TanChannel.SMS, + newPhone, + ); + await solveAutomaticallySentBrowserChallenge( + t, + page, + mfa, + TanChannel.EMAIL, + newEmail, + ); + await finishAndChallengeSequence(t, page, mfa, newEmail, newPhone); + await waitForDialogToClose(page); + await page.getByText("Account updated", { exact: true }).waitFor(); + + removeDeliveredCodes(mfa); + await page.goto(`${webui.url}#/wire-transfer/${publicAccount}`); + await browser.saveScreenshot("protected-transfer-form"); + await page + .getByLabel(/Transfer subject/) + .fill("browser protected transfer"); + await page.locator('input[name="amount"]').fill("7"); + await page.getByRole("button", { name: "Send", exact: true }).click(); + await solveBrowserChallenge(t, page, mfa, TanChannel.EMAIL, newEmail); + await waitForDialogToClose(page); + await page.waitForURL(/#\/account$/); + + const publicHistory = await bank.http.fetch( + new URL(`accounts/${publicAccount}/transactions`, bank.corebankApiBaseUrl) + .href, + { method: "GET" }, + ); + t.assertDeepEqual(publicHistory.status, HttpStatusCode.Ok); + const publicBody = (await publicHistory.json()) as { + transactions?: Array<{ subject?: string }>; + }; + t.assertTrue( + publicBody.transactions?.some( + (entry) => entry.subject === "browser protected transfer", + ) === true, + "anonymous public history did not expose the completed transfer", + ); + + const privateHistory = await bank.http.fetch( + new URL( + `accounts/${privateAccount}/transactions`, + bank.corebankApiBaseUrl, + ).href, + { method: "GET" }, + ); + t.assertDeepEqual(privateHistory.status, HttpStatusCode.NotFound); + } finally { + if (browser) await browser.close(); + await webui.close(); + } +} + +async function acceptAndConfirmWebuiWithdrawal(args: { + t: GlobalTestState; + page: Page; + walletClient: WalletClient; + exchangeBaseUrl: string; + amount?: AmountString; +}): Promise<void> { + const withdrawLink = args.page.getByRole("link", { + name: "Withdraw", + exact: true, + }); + await withdrawLink.waitFor({ state: "visible" }); + const uri = await withdrawLink.getAttribute("href"); + const parsedUri = uri ? TalerUris.parse(uri) : undefined; + args.t.assertTrue( + parsedUri?.tag === "ok" && parsedUri.value.type === TalerUriAction.Withdraw, + "Bank WebUI did not expose a valid withdrawal URI", + ); + + await args.walletClient.call(WalletOperation.GetWithdrawalDetailsForUri, { + talerWithdrawUri: uri!, + }); + const accepted = await args.walletClient.call( + WalletOperation.AcceptBankIntegratedWithdrawal, + { + exchangeBaseUrl: args.exchangeBaseUrl, + talerWithdrawUri: uri!, + amount: args.amount, + }, + ); + await args.walletClient.call(WalletOperation.TestingWaitTransactionState, { + transactionId: accepted.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankConfirmTransfer, + }, + }); + await args.page + .getByRole("button", { name: "Transfer", exact: true }) + .waitFor({ state: "visible" }); + await args.page + .getByRole("button", { name: "Transfer", exact: true }) + .click(); + await args.walletClient.call( + WalletOperation.TestingWaitTransactionsFinal, + {}, + ); +} + +/** + * Conversion quote/cashout plus both amount-fixed and wallet-selected Bank + * WebUI withdrawal forms, completed through a real wallet daemon. + */ +export async function runLibeufinBankWebuiMoneyFlowsTest(t: GlobalTestState) { + const environment = await createSimpleTestkudosEnvironmentV3( + t, + defaultCoinConfig.map((entry) => entry("TESTKUDOS")), + { forceLibeufin: true, walletLogLevel: "WARNING" }, + ); + const { bank, exchange, walletClient } = environment; + // LibEuFin validates that the exchange account already exists when + // conversion is enabled, so activate it only after the standard environment + // has registered that account. + const libeufinBank = bank as LibeufinBankService; + await libeufinBank.stop(); + const nexus = await LibeufinNexusService.create(t, { + currency: "FOO", + database: environment.commonDb.connStr, + httpPort: 8085, + }); + await nexus.dbinit(); + libeufinBank.changeConfig((config) => { + config.setString("libeufin-bank", "ALLOW_CONVERSION", "yes"); + config.setString("libeufin-bank", "FIAT_CURRENCY", "FOO"); + }); + await libeufinBank.start({ noReset: true }); + const user = "webui-money"; + const password = "webui-money-password"; + const api = new TalerCoreBankHttpClient(bank.corebankApiBaseUrl); + succeedOrThrow( + await api.createAccount(bank.getAdminAuth(), { + username: user, + password, + name: "Bank WebUI Money Flows", + payto_uri: getTestHarnessPaytoForLabel(user), + cashout_payto_uri: + "payto://iban/CH9300762011623852957?receiver-name=Bank%20WebUI%20Money%20Flows", + }), + ); + const conversion = new TalerBankConversionHttpClient( + new URL("conversion-info/", bank.corebankApiBaseUrl).href, + ); + succeedOrThrow( + await conversion.updateConversionRate(bank.getAdminAuth(), { + cashin_fee: "TESTKUDOS:0", + cashin_min_amount: "FOO:1", + cashin_ratio: "1", + cashin_rounding_mode: "nearest", + cashin_tiny_amount: "TESTKUDOS:0.01", + cashout_fee: "FOO:0", + cashout_min_amount: "TESTKUDOS:1", + cashout_ratio: "1", + cashout_rounding_mode: "nearest", + cashout_tiny_amount: "FOO:0.01", + }), + ); + + const webui = await startStaticServerBankWebui(bank.corebankApiBaseUrl); + let browser: BankWebuiBrowser | undefined; + try { + browser = await launchBankWebuiBrowser({ + browserType: "chromium", + screenshotDir: path.join(t.testDir, "bank-webui-money-screenshots"), + }); + const page = browser.page; + attachSafeBrowserDiagnostics(page); + await login(page, webui.url, user, password); + await page.getByRole("button", { name: "Log in", exact: true }).waitFor({ + state: "hidden", + }); + await page.goto(`${webui.url}#/account`); + await page + .getByText("Welcome, Bank WebUI Money Flows", { exact: true }) + .waitFor({ state: "visible" }); + + await page.goto(`${webui.url}#/new-cashout`); + await browser.saveScreenshot("cashout-form"); + await page + .getByLabel(/Transfer subject/) + .fill("browser conversion cashout"); + await page.locator('input[name="amount"]').fill("5"); + await page.getByText("Total cashout transfer", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Cashout", exact: true }).click(); + await page.waitForURL(/#\/account$/); + const admin = bank.getAdminAuth(); + t.assertTrue(admin.type === "bearer"); + const cashouts = succeedOrThrow(await api.getGlobalCashouts(admin.token)); + t.assertTrue( + cashouts.cashouts.some((entry) => entry.username === user), + "browser cashout was not persisted", + ); + + // Amount-fixed (legacy) form. + await page.goto(`${webui.url}#/account/charge-wallet`); + await page.locator('input[name="withdraw-amount"]').fill("10"); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await acceptAndConfirmWebuiWithdrawal({ + t, + page, + walletClient, + exchangeBaseUrl: exchange.baseUrl, + }); + + // Wallet-selected (fast) form. The preference switch is part of the + // ordinary header and changing it starts a fresh no-amount operation. + await page.goto(`${webui.url}#/account`); + const fastSwitch = page.getByRole("switch", { + name: "Withdraw without setting amount", + exact: true, + }); + await page.getByRole("button", { name: "Open settings" }).click(); + if ((await fastSwitch.getAttribute("aria-checked")) !== "true") { + await fastSwitch.click({ force: true }); + } + await page.getByRole("button", { name: "Close panel" }).click(); + await page.goto(`${webui.url}#/account/charge-wallet`); + await acceptAndConfirmWebuiWithdrawal({ + t, + page, + walletClient, + exchangeBaseUrl: exchange.baseUrl, + amount: "TESTKUDOS:10", + }); + + const balances = await walletClient.call(WalletOperation.GetBalances, {}); + t.assertAmountEquals(balances.balances[0].available, "TESTKUDOS:19.70"); + await browser.saveScreenshot("money-flows-complete"); + } finally { + if (browser) await browser.close(); + await webui.close(); + } +} + +runLibeufinBankWebuiCoreTest.timeoutMs = TEST_TIMEOUT_MS; +runLibeufinBankWebuiCoreTest.suites = ["web", "bank-webui", "libeufin", "mfa"]; + +runLibeufinBankWebuiMoneyFlowsTest.timeoutMs = TEST_TIMEOUT_MS; +runLibeufinBankWebuiMoneyFlowsTest.suites = [ + "web", + "bank-webui", + "libeufin", + "wallet", +]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -108,6 +108,10 @@ import { runKycWalletDepositAbortTest } from "./test-kyc-wallet-deposit-abort.js import { runKycWithdrawalVerbotenTest } from "./test-kyc-withdrawal-verboten.js"; import { runLibeufinBankTest } from "./test-libeufin-bank.js"; import { runLibeufinConversionTest } from "./test-libeufin-conversion.js"; +import { + runLibeufinBankWebuiCoreTest, + runLibeufinBankWebuiMoneyFlowsTest, +} from "./test-libeufin-bank-webui.js"; import { runMerchantAcctselTest } from "./test-merchant-acctsel.js"; import { runMerchantBankBadWireTargetTest } from "./test-merchant-bank-bad-wire-target.js"; import { runMerchantCategoriesTest } from "./test-merchant-categories.js"; @@ -376,6 +380,8 @@ const allTests: TestMainFunction[] = [ runPaymentExpiredTest, runWalletGenDbTest, runLibeufinBankTest, + runLibeufinBankWebuiCoreTest, + runLibeufinBankWebuiMoneyFlowsTest, runPaymentDeletedTest, runWalletDd48Test, runCurrencyScopeTest,