taler-typescript-core

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

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:
Mpackages/taler-harness/src/harness/webui-server.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-libeufin-bank-webui.ts | 848+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
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();