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:
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,