commit 66aa29cf6cf0a113090b4f492cac77ccab75a9cb
parent 0391e95f9fcf62f8975831976da4d47dc699952e
Author: Florian Dold <dold@taler.net>
Date: Tue, 25 Aug 2026 11:30:25 +0200
taler-harness: cover bank web UI workflows
Diffstat:
2 files changed, 812 insertions(+), 38 deletions(-)
diff --git a/packages/taler-harness/src/harness/webui-server.ts b/packages/taler-harness/src/harness/webui-server.ts
@@ -166,8 +166,8 @@ export async function startStaticServerBankWebui(
dynamicFiles: {
"/settings.json": (serverBaseUrl: string) => ({
backendBaseURL: serverBaseUrl,
- allowRandomAccountCreation: false,
showDemoDescription: false,
+ showPublicAccounts: false,
topNavSites: {},
defaultSuggestedAmount: 10,
}),
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-bank-webui.ts b/packages/taler-harness/src/integrationtests/test-libeufin-bank-webui.ts
@@ -15,6 +15,7 @@
*/
import {
+ Amounts,
AmountString,
HttpStatusCode,
TalerBankConversionHttpClient,
@@ -55,6 +56,10 @@ import { startStaticServerBankWebui } from "../harness/webui-server.js";
const TEST_TIMEOUT_MS = 300_000;
const USERNAME = "webui-user";
const PASSWORD = "webui-password";
+const REGISTERED_USERNAME = "webui-registered";
+const REGISTERED_PASSWORD = "registered-password";
+const FALLBACK_USERNAME = "webui-registration-fallback";
+const FALLBACK_PASSWORD = "fallback-password";
function removeDeliveredCodes(config: TestMfaChannelConfigEmailSms): void {
fs.rmSync(config.email.path, { force: true });
@@ -76,6 +81,18 @@ function attachSafeBrowserDiagnostics(page: Page): void {
});
}
+async function assertWithdrawalProgress(
+ t: GlobalTestState,
+ page: Page,
+ expected: string[],
+): Promise<void> {
+ const labels = page
+ .getByRole("navigation", { name: "Withdrawal progress" })
+ .locator("li > span:last-child");
+ await labels.first().waitFor({ state: "visible" });
+ t.assertDeepEqual(await labels.allTextContents(), expected);
+}
+
async function login(
page: Page,
webuiUrl: string,
@@ -84,8 +101,63 @@ async function login(
): Promise<void> {
await page.goto(`${webuiUrl}#/login`);
await page.getByLabel("Username", { exact: true }).fill(username);
+ const passwordField = page.getByLabel("Password", { exact: true });
+ await passwordField.fill(password);
+ if (
+ !(await passwordField.evaluate(
+ (element) => element === element.ownerDocument.activeElement,
+ ))
+ ) {
+ throw Error("the login form moved focus away from the password field");
+ }
+ await passwordField.press("Enter");
+}
+
+async function register(
+ page: Page,
+ webuiUrl: string,
+ name: string,
+ username: string,
+ password: string,
+): Promise<void> {
+ await page.goto(`${webuiUrl}#/register`);
+ await page.getByLabel("Full name", { exact: true }).fill(name);
+ 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();
+ const confirmation = page.getByLabel("Confirm password", { exact: true });
+ await confirmation.fill(password);
+ if (
+ !(await confirmation.evaluate(
+ (element) => element === element.ownerDocument.activeElement,
+ ))
+ ) {
+ throw Error(
+ "the registration form moved focus away from the confirmation field",
+ );
+ }
+ await confirmation.press("Enter");
+}
+
+async function logout(page: Page): Promise<void> {
+ await page.getByRole("button", { name: "Sign out", exact: true }).click();
+ await page.getByRole("button", { name: "Sign in", exact: true }).waitFor();
+}
+
+async function waitForLoggedIn(page: Page): Promise<void> {
+ await page.waitForFunction(() => {
+ const storage = (
+ globalThis as unknown as {
+ localStorage: { getItem(key: string): string | null };
+ }
+ ).localStorage;
+ const storedSession = storage.getItem("bank-session");
+ if (!storedSession) return false;
+ try {
+ return JSON.parse(storedSession).status === "loggedIn";
+ } catch {
+ return false;
+ }
+ });
}
async function solveBrowserChallenge(
@@ -110,7 +182,7 @@ async function solveBrowserChallenge(
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();
+ await dialog.getByLabel("Code", { exact: true }).press("Enter");
}
async function solveAutomaticallySentBrowserChallenge(
@@ -125,7 +197,7 @@ async function solveAutomaticallySentBrowserChallenge(
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();
+ await dialog.getByLabel("Code", { exact: true }).press("Enter");
}
async function waitForDialogToClose(page: Page): Promise<void> {
@@ -182,7 +254,7 @@ async function finishAndChallengeSequence(
);
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();
+ await dialog.getByLabel("Code", { exact: true }).press("Enter");
}
throw Error("AND challenge sequence did not become complete");
}
@@ -208,6 +280,7 @@ export async function runLibeufinBankWebuiCoreTest(t: GlobalTestState) {
let mfa!: TestMfaChannelConfigEmailSms;
bank.changeConfig((config) => {
mfa = configureTestBankMfa(t, config, oldEmail, oldPhone);
+ config.setString("libeufin-bank", "ALLOW_ACCOUNT_DELETION", "yes");
});
await bank.start();
@@ -252,17 +325,264 @@ export async function runLibeufinBankWebuiCoreTest(t: GlobalTestState) {
const page = browser.page;
attachSafeBrowserDiagnostics(page);
+ await page.goto(`${webui.url}#/login`);
+ await page.getByRole("button", { name: "Sign in", exact: true }).waitFor();
+ await browser.saveScreenshot("login");
+ t.assertDeepEqual(
+ await page
+ .getByRole("link", { name: "Public accounts", exact: true })
+ .count(),
+ 0,
+ );
+ await page.goto(`${webui.url}#/dev`);
+ const demoBannerSwitch = page.getByRole("switch", {
+ name: "Show demo banner",
+ exact: true,
+ });
+ if ((await demoBannerSwitch.getAttribute("aria-checked")) !== "true") {
+ await demoBannerSwitch.click();
+ }
+ const publicAccountsSwitch = page.getByRole("switch", {
+ name: "Show public accounts",
+ exact: true,
+ });
+ if ((await publicAccountsSwitch.getAttribute("aria-checked")) !== "true") {
+ await publicAccountsSwitch.click();
+ }
+ await page
+ .getByRole("button", { name: "Apply overrides", exact: true })
+ .click();
+ await page.goto(`${webui.url}#/login`);
+ await page.getByAltText("GNU Taler logo", { exact: true }).waitFor();
+ await page
+ .getByRole("link", { name: "Public accounts", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("login-demo-banner");
+ const demoNavigation = page.getByRole("navigation", {
+ name: "Demo pages",
+ exact: true,
+ });
+ await demoNavigation.evaluate((element) => {
+ element.ownerDocument.body.style.minHeight = "2000px";
+ });
+ await page.evaluate(() => {
+ (
+ globalThis as unknown as {
+ scrollTo: (x: number, y: number) => void;
+ }
+ ).scrollTo(0, 150);
+ });
+ const scrollY = await page.evaluate(
+ () => (globalThis as unknown as { scrollY: number }).scrollY,
+ );
+ t.assertTrue(scrollY >= 100, "the demo page should have scrolled");
+ const demoNavigationBox = await demoNavigation.boundingBox();
+ t.assertTrue(
+ demoNavigationBox !== null && Math.abs(demoNavigationBox.y) < 2,
+ "the demo navigation should remain at the top while scrolling",
+ );
+ t.assertDeepEqual(
+ await page.getByAltText("GNU Taler", { exact: true }).count(),
+ 0,
+ );
+ t.assertDeepEqual(
+ await page.getByAltText("language", { exact: true }).count(),
+ 0,
+ );
+ await demoNavigation.evaluate((element) => {
+ element.ownerDocument.body.style.minHeight = "";
+ });
+ await page.goto(`${webui.url}#/dev`);
+ const enabledDemoBannerSwitch = page.getByRole("switch", {
+ name: "Show demo banner",
+ exact: true,
+ });
+ if (
+ (await enabledDemoBannerSwitch.getAttribute("aria-checked")) === "true"
+ ) {
+ await enabledDemoBannerSwitch.click();
+ }
+ await page
+ .getByRole("button", { name: "Apply overrides", exact: true })
+ .click();
+
+ let rejectAutomaticLogin = true;
+ const fallbackTokenUrl = `**/accounts/${FALLBACK_USERNAME}/token`;
+ await page.route(fallbackTokenUrl, async (route) => {
+ if (rejectAutomaticLogin) {
+ rejectAutomaticLogin = false;
+ await route.abort("connectionfailed");
+ } else {
+ await route.continue();
+ }
+ });
+ await page.goto(`${webui.url}#/register`);
+ await page
+ .getByRole("heading", { name: "Create your bank account", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("registration");
+ await register(
+ page,
+ webui.url,
+ "Registration Fallback User",
+ FALLBACK_USERNAME,
+ FALLBACK_PASSWORD,
+ );
+ await page
+ .getByText("Your account was created. Sign in to continue.", {
+ exact: true,
+ })
+ .waitFor();
+ await browser.saveScreenshot("registration-fallback");
+ t.assertDeepEqual(
+ await page.getByLabel("Username", { exact: true }).inputValue(),
+ FALLBACK_USERNAME,
+ );
+ await page.unroute(fallbackTokenUrl);
+ await page.getByLabel("Password", { exact: true }).fill(FALLBACK_PASSWORD);
+ await page.getByLabel("Password", { exact: true }).press("Enter");
+ await waitForLoggedIn(page);
+ await page.goto(`${webui.url}#/account`);
+ await page
+ .getByText("Welcome, Registration Fallback User", { exact: true })
+ .waitFor();
+ await logout(page);
+
+ await register(
+ page,
+ webui.url,
+ "Registered Bank WebUI User",
+ REGISTERED_USERNAME,
+ REGISTERED_PASSWORD,
+ );
+ await waitForLoggedIn(page);
+ await page.waitForURL(/#\/account$/);
+ await page
+ .getByText("Welcome, Registered Bank WebUI User", { exact: true })
+ .waitFor();
+ const accountSummary = page.locator(
+ 'section[aria-labelledby="current-balance-title"]',
+ );
+ await accountSummary
+ .getByText("Current balance", { exact: true })
+ .waitFor();
+ await accountSummary
+ .getByText("Registered Bank WebUI User", { exact: true })
+ .waitFor();
+ await accountSummary
+ .getByText(`${REGISTERED_USERNAME} @ localhost`, { exact: true })
+ .waitFor();
+ const accountAddressButton = accountSummary.getByRole("button", {
+ name: `${REGISTERED_USERNAME} @ localhost`,
+ exact: true,
+ });
+ await accountAddressButton.click();
+ const copyAccountAddressButton = accountSummary.getByRole("button", {
+ name: "Copy account address",
+ exact: true,
+ });
+ await copyAccountAddressButton.waitFor();
+ await browser.saveScreenshot("account-address-menu");
+ await accountAddressButton.press("Escape");
+ await copyAccountAddressButton.waitFor({ state: "hidden" });
+ await accountAddressButton.click();
+ await copyAccountAddressButton.click();
+ await copyAccountAddressButton.waitFor({ state: "hidden" });
+ t.assertDeepEqual(
+ await page
+ .getByRole("navigation", { name: "Bank account", exact: true })
+ .count(),
+ 0,
+ );
+ await page
+ .getByRole("link", { name: "Go to account details", exact: true })
+ .click();
+ await page.waitForURL(/#\/my-profile$/);
+ await browser.saveScreenshot("account-details");
+ const accountBackToOverview = page.getByRole("link", {
+ name: "Back to overview",
+ exact: true,
+ });
+ await accountBackToOverview.waitFor();
+ const accountNavigation = page.getByRole("navigation", {
+ name: "My account settings",
+ exact: true,
+ });
+ t.assertDeepEqual(
+ await accountNavigation
+ .getByRole("link", { name: "Profile", exact: true })
+ .getAttribute("aria-current"),
+ "page",
+ );
+ await page
+ .getByRole("link", { name: "Delete account", exact: true })
+ .waitFor();
+ await accountNavigation
+ .getByRole("link", { name: "Security", exact: true })
+ .click();
+ await page.waitForURL(/#\/my-password$/);
+ await page
+ .getByRole("heading", { name: "Update password", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("update-password");
+ await accountBackToOverview.click();
+ await page.waitForURL(/#\/account$/);
+ await page.goto(`${webui.url}#/public-accounts`);
+ await page
+ .getByRole("heading", { name: "Public accounts", exact: true })
+ .waitFor();
+ t.assertDeepEqual(
+ await page
+ .getByRole("navigation", { name: "Bank account", exact: true })
+ .count(),
+ 0,
+ );
+ await page.goto(`${webui.url}#/account`);
+ await logout(page);
+
+ await page.goto(`${webui.url}#/dev`);
+ await page
+ .getByRole("button", { name: "Clear overrides", exact: true })
+ .click();
+ await page.goto(`${webui.url}#/login`);
+ t.assertDeepEqual(
+ await page
+ .getByRole("link", { name: "Public accounts", exact: true })
+ .count(),
+ 0,
+ );
+ await page.goto(`${webui.url}#/public-accounts`);
+ await page
+ .getByRole("heading", { name: "Public accounts", exact: true })
+ .waitFor();
+
+ await login(page, webui.url, "missing-webui-user", "missing-password");
+ // The token endpoint intentionally does not reveal whether an account
+ // exists, so an unknown username has the same response as a bad password.
+ await page
+ .getByText("The password is incorrect.", { exact: true })
+ .waitFor();
+ await login(page, webui.url, USERNAME, "wrong-password");
+ await page
+ .getByText("The password is incorrect.", { exact: true })
+ .waitFor();
+ t.assertDeepEqual(
+ await page
+ .locator("div.fixed")
+ .filter({ hasText: "The password is incorrect." })
+ .count(),
+ 0,
+ );
+
removeDeliveredCodes(mfa);
await login(page, webui.url, USERNAME, PASSWORD);
// An OR challenge must allow an explicit channel choice.
+ await page.locator("dialog[open]").waitFor({ state: "visible" });
+ await browser.saveScreenshot("mfa-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 waitForLoggedIn(page);
+ await page.waitForURL(/#\/account$/);
await browser.saveScreenshot("signed-in");
// Changing both contacts first proves an existing OR channel, then proves
@@ -305,11 +625,14 @@ export async function runLibeufinBankWebuiCoreTest(t: GlobalTestState) {
removeDeliveredCodes(mfa);
await page.goto(`${webui.url}#/wire-transfer/${publicAccount}`);
await browser.saveScreenshot("protected-transfer-form");
+ await page.setViewportSize({ width: 390, height: 844 });
+ await browser.saveScreenshot("protected-transfer-form-mobile");
+ await page.setViewportSize({ width: 1280, height: 1024 });
await page
- .getByLabel(/Transfer subject/)
+ .getByLabel(/Transfer reference/)
.fill("browser protected transfer");
await page.locator('input[name="amount"]').fill("7");
- await page.getByRole("button", { name: "Send", exact: true }).click();
+ await page.locator('input[name="amount"]').press("Enter");
await solveBrowserChallenge(t, page, mfa, TanChannel.EMAIL, newEmail);
await waitForDialogToClose(page);
await page.waitForURL(/#\/account$/);
@@ -330,6 +653,104 @@ export async function runLibeufinBankWebuiCoreTest(t: GlobalTestState) {
"anonymous public history did not expose the completed transfer",
);
+ await page.goto(`${webui.url}#/account`);
+ await page
+ .getByRole("link", { name: "Withdraw to Taler Wallet", exact: true })
+ .waitFor();
+ await page
+ .getByRole("link", { name: "Send bank transfer", exact: true })
+ .waitFor();
+ await page
+ .getByText(`${publicAccount} @ localhost`, { exact: true })
+ .waitFor();
+ await browser.saveScreenshot("overview-desktop");
+ await page.setViewportSize({ width: 390, height: 844 });
+ await browser.saveScreenshot("overview-mobile");
+ await page.setViewportSize({ width: 1280, height: 1024 });
+
+ await page
+ .getByRole("link", { name: "Send bank transfer", exact: true })
+ .click();
+ await page.waitForURL(/#\/account\/wire-transfer$/);
+ await browser.saveScreenshot("transfer-form-before-error");
+ const transferForm = page.locator("form");
+ await transferForm.getByLabel(/^Recipient/).fill("fd42");
+ await transferForm
+ .getByLabel(/^Transfer reference/)
+ .fill("browser missing recipient");
+ await transferForm.locator('input[name="amount"]').fill("1");
+ await transferForm.locator('input[name="amount"]').press("Enter");
+ const transferError = transferForm.getByRole("alert");
+ await transferError.waitFor({ state: "visible" });
+ await transferError
+ .getByText("Failed to create the transaction.", { exact: true })
+ .waitFor();
+ await transferError
+ .getByText(/destination account .* was not found/)
+ .waitFor();
+ t.assertDeepEqual(
+ await transferForm.getByLabel(/^Recipient/).inputValue(),
+ "fd42",
+ );
+ t.assertDeepEqual(
+ await transferForm
+ .getByLabel(/^Transfer reference/)
+ .inputValue(),
+ "browser missing recipient",
+ );
+ t.assertDeepEqual(
+ await transferForm.locator('input[name="amount"]').inputValue(),
+ "1",
+ );
+ await browser.saveScreenshot("transfer-error-inline");
+ await page.setViewportSize({ width: 390, height: 844 });
+ await browser.saveScreenshot("transfer-error-inline-mobile");
+ await page.setViewportSize({ width: 1280, height: 1024 });
+ await page
+ .getByRole("link", { name: "Back to overview", exact: true })
+ .click();
+ await page.waitForURL(/#\/account$/);
+ await page
+ .getByRole("link", { name: "Send bank transfer", exact: true })
+ .click();
+ await page.waitForURL(/#\/account\/wire-transfer$/);
+ t.assertDeepEqual(
+ await page
+ .getByText("Failed to create the transaction.", { exact: true })
+ .count(),
+ 0,
+ );
+ await page
+ .getByRole("link", { name: "Back to overview", exact: true })
+ .click();
+ await page.waitForURL(/#\/account$/);
+
+ await page
+ .getByRole("link", { name: "View all transactions", exact: true })
+ .click();
+ await page.waitForURL(/#\/transactions$/);
+ await page
+ .getByRole("heading", { name: "Transactions", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("transactions");
+ await page.setViewportSize({ width: 390, height: 844 });
+ await browser.saveScreenshot("transactions-mobile");
+ await page.setViewportSize({ width: 1280, height: 1024 });
+ const backToOverview = page.getByRole("link", {
+ name: "Back to overview",
+ exact: true,
+ });
+ await backToOverview.waitFor();
+ await backToOverview.click();
+ await page.waitForURL(/#\/account$/);
+
+ await page.goto(`${webui.url}#/public-accounts`);
+ await page
+ .getByRole("heading", { name: "Public accounts", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("public-accounts");
+ await page.goto(`${webui.url}#/account`);
+
const privateHistory = await bank.http.fetch(
new URL(
`accounts/${privateAccount}/transactions`,
@@ -338,6 +759,81 @@ export async function runLibeufinBankWebuiCoreTest(t: GlobalTestState) {
{ method: "GET" },
);
t.assertDeepEqual(privateHistory.status, HttpStatusCode.NotFound);
+
+ await logout(page);
+ await login(page, webui.url, "admin", "admin-password");
+ await waitForLoggedIn(page);
+ await page.waitForURL(/#\/account$/);
+ const adminNavigation = page.getByRole("navigation", {
+ name: "Bank administration",
+ exact: true,
+ });
+ await adminNavigation
+ .getByRole("link", { name: "Dashboard", exact: true })
+ .waitFor();
+ await page
+ .getByRole("heading", { name: "Admin account activity", exact: true })
+ .waitFor();
+ t.assertTrue(
+ (await page.getByText("Account updated", { exact: true }).count()) === 0,
+ "notifications from the previous session must be cleared on sign out",
+ );
+ await browser.saveScreenshot("admin-dashboard");
+ await page
+ .getByRole("link", { name: "Download stats as CSV", exact: true })
+ .click();
+ await page.waitForURL(/#\/download-stats$/);
+ await page
+ .getByRole("heading", { name: "Download bank statistics", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("download-bank-statistics");
+ await page
+ .getByRole("link", { name: "Back to dashboard", exact: true })
+ .click();
+ await page.waitForURL(/#\/account$/);
+ await adminNavigation
+ .getByRole("link", { name: "Accounts", exact: true })
+ .click();
+ await page.waitForURL(/#\/admin\/accounts$/);
+ await page
+ .getByRole("heading", { name: "Accounts", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("admin-accounts");
+ await page.setViewportSize({ width: 390, height: 844 });
+ await browser.saveScreenshot("admin-accounts-mobile");
+ await page.setViewportSize({ width: 1280, height: 1024 });
+ await page
+ .getByRole("link", { name: "Create account", exact: true })
+ .click();
+ await page.waitForURL(/#\/new-account$/);
+ await page
+ .getByRole("heading", { name: "New bank account", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("create-bank-account");
+ await page
+ .getByRole("link", { name: "Back to accounts", exact: true })
+ .click();
+ await page.waitForURL(/#\/admin\/accounts$/);
+ const accountSearch = page.getByLabel("Search accounts", { exact: true });
+ await accountSearch.fill(publicAccount);
+ const filteredRequest = page.waitForRequest(
+ (request) =>
+ request.url().includes("/accounts?") &&
+ new URL(request.url()).searchParams.get("filter_name") ===
+ publicAccount,
+ );
+ await accountSearch.press("Enter");
+ await filteredRequest;
+ await page
+ .getByRole("link", { name: publicAccount, exact: true })
+ .waitFor();
+ await adminNavigation
+ .getByRole("link", { name: "Admin activity", exact: true })
+ .click();
+ await page.waitForURL(/#\/admin\/activity$/);
+ await page
+ .getByRole("heading", { name: "Admin account activity", exact: true })
+ .waitFor();
} finally {
if (browser) await browser.close();
await webui.close();
@@ -350,13 +846,21 @@ async function acceptAndConfirmWebuiWithdrawal(args: {
walletClient: WalletClient;
exchangeBaseUrl: string;
amount?: AmountString;
+ bankAmount?: AmountString;
+ withdrawalUri?: string;
+ expectedAmount: AmountString;
+ expectedFee: AmountString;
+ exerciseResume?: boolean;
}): Promise<void> {
- const withdrawLink = args.page.getByRole("link", {
- name: "Withdraw",
- exact: true,
- });
- await withdrawLink.waitFor({ state: "visible" });
- const uri = await withdrawLink.getAttribute("href");
+ let uri = args.withdrawalUri;
+ if (!uri) {
+ const withdrawLink = args.page.getByRole("link", {
+ name: "Open Taler Wallet manually",
+ exact: true,
+ });
+ await withdrawLink.waitFor({ state: "visible" });
+ uri = (await withdrawLink.getAttribute("href")) ?? undefined;
+ }
const parsedUri = uri ? TalerUris.parse(uri) : undefined;
args.t.assertTrue(
parsedUri?.tag === "ok" && parsedUri.value.type === TalerUriAction.Withdraw,
@@ -381,11 +885,76 @@ async function acceptAndConfirmWebuiWithdrawal(args: {
minor: TransactionMinorState.BankConfirmTransfer,
},
});
+ const confirmationDialog = args.page.getByRole("dialog", {
+ name: "Confirm wallet withdrawal",
+ exact: true,
+ });
+ await confirmationDialog.waitFor({ state: "visible" });
+ await confirmationDialog
+ .getByText("Wallet receives", { exact: true })
+ .waitFor();
+ if (args.bankAmount) {
+ await confirmationDialog
+ .getByLabel("Amount to your Taler Wallet", { exact: true })
+ .fill(Amounts.stringifyValue(Amounts.parseOrThrow(args.bankAmount)));
+ }
+ const fee = Amounts.parseOrThrow(args.expectedFee);
+ const total = Amounts.add(
+ Amounts.parseOrThrow(args.expectedAmount),
+ fee,
+ ).amount;
+ const feeRow = confirmationDialog
+ .getByText("Bank fee", { exact: true })
+ .locator("..");
+ const totalRow = confirmationDialog
+ .getByText("Total debited", { exact: true })
+ .locator("..");
+ args.t.assertTrue(
+ (await feeRow.innerText()).includes(Amounts.stringifyValue(fee)),
+ "withdrawal confirmation did not show the bank fee",
+ );
+ args.t.assertTrue(
+ (await totalRow.innerText()).includes(Amounts.stringifyValue(total)),
+ "withdrawal confirmation did not show the total debit",
+ );
+ args.t.assertTrue(
+ await confirmationDialog.evaluate((element) => element.matches(":modal")),
+ "withdrawal confirmation did not use the browser modal layer",
+ );
+ await args.page.keyboard.press("Escape");
+ args.t.assertTrue(
+ await confirmationDialog.isVisible(),
+ "Escape dismissed the withdrawal confirmation",
+ );
+
+ if (args.exerciseResume) {
+ await args.page.goBack();
+ await confirmationDialog.waitFor({ state: "visible" });
+ await args.page
+ .getByRole("button", { name: "Finish later", exact: true })
+ .click();
+ await confirmationDialog.waitFor({ state: "hidden" });
+ await args.page
+ .getByRole("heading", {
+ name: "Withdrawal ready for confirmation",
+ exact: true,
+ })
+ .waitFor({ state: "visible" });
+ await args.page.reload();
+ await args.page
+ .getByRole("heading", {
+ name: "Withdrawal ready for confirmation",
+ exact: true,
+ })
+ .waitFor({ state: "visible" });
+ await args.page
+ .getByRole("link", { name: "Review withdrawal", exact: true })
+ .click();
+ await confirmationDialog.waitFor({ state: "visible" });
+ }
+
await args.page
- .getByRole("button", { name: "Transfer", exact: true })
- .waitFor({ state: "visible" });
- await args.page
- .getByRole("button", { name: "Transfer", exact: true })
+ .getByRole("button", { name: "Confirm withdrawal", exact: true })
.click();
await args.walletClient.call(
WalletOperation.TestingWaitTransactionsFinal,
@@ -418,6 +987,7 @@ export async function runLibeufinBankWebuiMoneyFlowsTest(t: GlobalTestState) {
libeufinBank.changeConfig((config) => {
config.setString("libeufin-bank", "ALLOW_CONVERSION", "yes");
config.setString("libeufin-bank", "FIAT_CURRENCY", "FOO");
+ config.setString("libeufin-bank", "WIRE_TRANSFER_FEES", "TESTKUDOS:0.1");
});
await libeufinBank.start({ noReset: true });
const user = "webui-money";
@@ -461,22 +1031,21 @@ export async function runLibeufinBankWebuiMoneyFlowsTest(t: GlobalTestState) {
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 waitForLoggedIn(page);
+ await page.waitForURL(/#\/account$/);
await page
.getByText("Welcome, Bank WebUI Money Flows", { exact: true })
.waitFor({ state: "visible" });
- await page.goto(`${webui.url}#/new-cashout`);
+ await page.locator('a[name="cash out"]').click();
+ await page.waitForURL(/#\/new-cashout$/);
await browser.saveScreenshot("cashout-form");
await page
- .getByLabel(/Transfer subject/)
+ .getByLabel(/Transfer reference/)
.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.locator('input[name="amount"]').press("Enter");
await page.waitForURL(/#\/account$/);
const admin = bank.getAdminAuth();
t.assertTrue(admin.type === "bearer");
@@ -486,41 +1055,246 @@ export async function runLibeufinBankWebuiMoneyFlowsTest(t: GlobalTestState) {
"browser cashout was not persisted",
);
- // Amount-fixed (legacy) form.
+ await page.goto(`${webui.url}#/my-cashouts`);
+ await page
+ .getByRole("heading", { name: "Cashout history", exact: true })
+ .waitFor();
+ await page
+ .getByRole("link", { name: "View details", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("cashout-history");
+ await page.setViewportSize({ width: 390, height: 844 });
+ await browser.saveScreenshot("cashout-history-mobile");
+ await page.setViewportSize({ width: 1280, height: 1024 });
+ await page.getByRole("link", { name: "View details", exact: true }).click();
+ await page.waitForURL(/#\/cashout\/[0-9]+$/);
+ await page
+ .getByRole("heading", { name: "Cashout details", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("cashout-details");
+ await page
+ .getByRole("link", { name: "Back to cashout history", exact: true })
+ .click();
+ await page.waitForURL(/#\/my-cashouts$/);
+
+ // Amount-fixed 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 assertWithdrawalProgress(t, page, [
+ "Choose amount",
+ "Open wallet",
+ "Review",
+ ]);
+ const withdrawalAmount = page.locator('input[name="withdraw-amount"]');
+ await withdrawalAmount.fill("10");
+ await page.getByText("Available to withdraw", { exact: true }).waitFor();
+ const amountFeeRow = page
+ .getByText("Bank fee", { exact: true })
+ .locator("..");
+ const amountTotalRow = page
+ .getByText("Total debited", { exact: true })
+ .locator("..");
+ t.assertTrue(
+ (await amountFeeRow.innerText()).includes("0.1"),
+ "withdrawal amount form did not show the bank fee",
+ );
+ t.assertTrue(
+ (await amountTotalRow.innerText()).includes("10.1"),
+ "withdrawal amount form did not show the total debit",
+ );
+ await browser.saveScreenshot("withdrawal-amount");
+ await withdrawalAmount.press("Enter");
+ const startedWithdrawal = page.getByRole("link", {
+ name: "Open Taler Wallet manually",
+ exact: true,
+ });
+ await startedWithdrawal.waitFor({ state: "visible" });
+ await assertWithdrawalProgress(t, page, [
+ "Choose amount",
+ "Open wallet",
+ "Review",
+ ]);
+ await browser.saveScreenshot("withdrawal-started");
await acceptAndConfirmWebuiWithdrawal({
t,
page,
walletClient,
exchangeBaseUrl: exchange.baseUrl,
+ expectedAmount: "TESTKUDOS:10",
+ expectedFee: "TESTKUDOS:0.1",
+ exerciseResume: true,
});
- // Wallet-selected (fast) form. The preference switch is part of the
- // ordinary header and changing it starts a fresh no-amount operation.
+ // Wallet-selected (fast) form. Changing the footer-dialog preference
+ // 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();
+ await page
+ .getByRole("button", { name: "Open interface preferences" })
+ .click();
if ((await fastSwitch.getAttribute("aria-checked")) !== "true") {
await fastSwitch.click({ force: true });
}
- await page.getByRole("button", { name: "Close panel" }).click();
+ await page.getByRole("button", { name: "Done", exact: true }).click();
await page.goto(`${webui.url}#/account/charge-wallet`);
+ await assertWithdrawalProgress(t, page, [
+ "Open wallet",
+ "Choose amount",
+ "Review",
+ ]);
await acceptAndConfirmWebuiWithdrawal({
t,
page,
walletClient,
exchangeBaseUrl: exchange.baseUrl,
amount: "TESTKUDOS:10",
+ expectedAmount: "TESTKUDOS:10",
+ expectedFee: "TESTKUDOS:0.1",
+ });
+
+ // A cash-acceptor-style operation leaves amount selection to the bank.
+ const accessToken = succeedOrThrow(
+ await api.createAccessToken(
+ user,
+ { type: "basic", username: user, password },
+ { scope: "readwrite" },
+ ),
+ ).access_token;
+ const bankSelectedWithdrawal = succeedOrThrow(
+ await api.createWithdrawal(
+ { username: user, token: accessToken },
+ { no_amount_to_wallet: true },
+ ),
+ );
+ await page.goto(
+ `${webui.url}#/start-operation/${bankSelectedWithdrawal.withdrawal_id}`,
+ );
+ await acceptAndConfirmWebuiWithdrawal({
+ t,
+ page,
+ walletClient,
+ exchangeBaseUrl: exchange.baseUrl,
+ withdrawalUri: `${bankSelectedWithdrawal.taler_withdraw_uri}?external-confirmation=1`,
+ bankAmount: "TESTKUDOS:10",
+ expectedAmount: "TESTKUDOS:10",
+ expectedFee: "TESTKUDOS:0.1",
});
+ // A pending operation can be left, resumed from the global notice, and
+ // explicitly aborted without leaving an orphaned active-operation record.
+ await page.goto(`${webui.url}#/account/charge-wallet`);
+ const pendingWithdrawLink = page.getByRole("link", {
+ name: "Open Taler Wallet manually",
+ exact: true,
+ });
+ await pendingWithdrawLink.waitFor({ state: "visible" });
+ const pendingUri = await pendingWithdrawLink.getAttribute("href");
+ const parsedPendingUri = pendingUri
+ ? TalerUris.parse(pendingUri)
+ : undefined;
+ if (
+ parsedPendingUri?.tag !== "ok" ||
+ parsedPendingUri.value.type !== TalerUriAction.Withdraw
+ ) {
+ throw Error("Bank WebUI did not expose an abortable withdrawal URI");
+ }
+ const abortedOperationId = parsedPendingUri.value.withdrawalOperationId;
+ await page.goBack();
+ await page
+ .getByRole("heading", {
+ name: "Withdrawal waiting for your wallet",
+ exact: true,
+ })
+ .waitFor({ state: "visible" });
+ await page
+ .locator('section[aria-labelledby="active-withdrawal-title"]')
+ .getByRole("button", { name: "Abort withdrawal", exact: true })
+ .click();
+ const abortDialog = page.getByRole("dialog", {
+ name: "Abort this withdrawal?",
+ exact: true,
+ });
+ await abortDialog.waitFor({ state: "visible" });
+ await abortDialog
+ .getByRole("button", { name: "Abort withdrawal", exact: true })
+ .click();
+ await page.waitForURL(/#\/account$/);
+ const aborted = succeedOrThrow(
+ await api.getWithdrawalById(abortedOperationId),
+ );
+ t.assertDeepEqual(aborted.status, "aborted");
+ t.assertDeepEqual(
+ await page.locator('a[name="charge wallet"]').getAttribute("href"),
+ "#/account/charge-wallet",
+ );
+
const balances = await walletClient.call(WalletOperation.GetBalances, {});
- t.assertAmountEquals(balances.balances[0].available, "TESTKUDOS:19.70");
+ t.assertAmountEquals(balances.balances[0].available, "TESTKUDOS:29.55");
await browser.saveScreenshot("money-flows-complete");
+
+ await logout(page);
+ await login(page, webui.url, "admin", "admin-password");
+ await waitForLoggedIn(page);
+ const adminNavigation = page.getByRole("navigation", {
+ name: "Bank administration",
+ exact: true,
+ });
+ await adminNavigation
+ .getByRole("link", { name: "Conversion", exact: true })
+ .click();
+ await page.waitForURL(/#\/admin\/conversion$/);
+ await page
+ .getByRole("heading", { name: "Conversion rate classes", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("conversion-classes");
+ await page
+ .getByRole("link", {
+ name: "Create conversion rate class",
+ exact: true,
+ })
+ .click();
+ await page.waitForURL(/#\/new-conversion-rate-class$/);
+ await page
+ .getByRole("heading", {
+ name: "New conversion rate class",
+ exact: true,
+ })
+ .waitFor();
+ await browser.saveScreenshot("conversion-class-create");
+ await page.locator("#class-name").fill("Standard member");
+ await page
+ .locator("#class-description")
+ .fill("Standard conversion terms for member accounts");
+ await page
+ .getByRole("button", { name: "Create class", exact: true })
+ .click();
+ await page.waitForURL(/#\/conversion-rate-class\/\d+\/details$/);
+ await page
+ .getByRole("heading", { name: "Standard member", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("conversion-class-details");
+ await page
+ .getByRole("button", { name: "Cashout settings", exact: true })
+ .click();
+ await browser.saveScreenshot("conversion-class-cashout");
+ await page
+ .getByRole("link", { name: "Back to conversion settings", exact: true })
+ .click();
+ await page.waitForURL(/#\/admin\/conversion$/);
+ await page
+ .getByRole("link", { name: "Default conversion rate", exact: true })
+ .click();
+ await page.waitForURL(/#\/conversion$/);
+ await page
+ .getByRole("heading", { name: "Default conversion rate", exact: true })
+ .waitFor();
+ await browser.saveScreenshot("default-conversion-rate");
+ await page.getByText("Cashout settings", { exact: true }).click();
+ await browser.saveScreenshot("default-conversion-rate-cashout");
+ await page.getByText("Cash-in settings", { exact: true }).click();
+ await browser.saveScreenshot("default-conversion-rate-cashin");
} finally {
if (browser) await browser.close();
await webui.close();