commit 2bf1b48cd7f628de0eb189354d73c153e3a9788f parent 81644e3d24ff350636955ef1f518c0f50ba05278 Author: Florian Dold <dold@taler.net> Date: Sun, 20 Sep 2026 01:04:54 +0200 wallet-webui: restore browser checkout without extra prompts Issue: https://bugs.taler.net/n/11809 Diffstat:
14 files changed, 903 insertions(+), 71 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-demo.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-demo.ts @@ -122,8 +122,6 @@ export async function runWalletWebUiDemoTest(t: GlobalTestState) { await stage.step("simulated merchant payment", async (page) => { await page.getByRole("button", { name: /Try a demo scenario/ }).click(); await page.getByRole("menuitem", { name: "Sample payment" }).click(); - await page.getByRole("heading", { name: "Wallet action" }).waitFor(); - await page.getByRole("button", { name: "Continue" }).click(); await page.getByRole("heading", { name: "Review payment" }).waitFor(); await page.getByText("Museum admission", { exact: true }).waitFor(); await page.goto(`${demoUrl}#/transactions`); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-integration.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-integration.ts @@ -18,6 +18,7 @@ import { loadSeleniumFirefox, } from "../harness/browser-dependencies.js"; import { GlobalTestState } from "../harness/harness.js"; +import { exerciseBrowserCheckout } from "./wallet-web-ui-checkout-test-support.js"; import { findBrowserBinary } from "../stagefright/stage.js"; import { findWalletWebUiDirectory, @@ -515,6 +516,32 @@ export async function runWalletWebUiChromeExtensionIntegrationTest( new nodeUrl.URL(worker.url()).hostname, worker.url(), ); + const checkout = await context.newPage(); + for (const page of context.pages()) { + if (page !== checkout) await page.close(); + } + const extensionId = new nodeUrl.URL(worker.url()).hostname; + await exerciseBrowserCheckout(t, { + walletUrl: `chrome-extension://${extensionId}/wallet.html`, + goto: (url) => checkout.goto(url), + click: (name) => + checkout.getByRole("button", { name, exact: true }).click(), + waitHeading: (name) => + checkout.getByRole("heading", { name, exact: true }).waitFor(), + waitUrl: (url) => checkout.waitForURL(url), + tabCount: async () => context.pages().length, + balance: () => + checkout + .getByRole("heading", { name: /Available balance TESTKUDOS:/ }) + .getAttribute("aria-label", { timeout: 60_000 }), + setAutomaticOpening: () => + chromeSetSettings(checkout, { + autoOpen: true, + injectApi: false, + allowCallback: false, + hijackLinks: true, + }), + }); } finally { await context.close(); } @@ -832,6 +859,57 @@ export async function runWalletWebUiFirefoxExtensionIntegrationTest( 5_000, ); assert.equal(await firefoxValue(driver, "return window.fallbackCount"), 1); + const checkoutHandle = await driver.getWindowHandle(); + for (const handle of await driver.getAllWindowHandles()) { + if (handle === checkoutHandle) continue; + await driver.switchTo().window(handle); + await driver.close(); + } + await driver.switchTo().window(checkoutHandle); + await exerciseBrowserCheckout(t, { + walletUrl: `moz-extension://${EXTENSION_UUID}/wallet.html`, + goto: (url) => driver.get(url), + click: async (label) => { + const button = await driver.wait( + selenium.until.elementLocated( + selenium.By.xpath(`//button[normalize-space(.)='${label}']`), + ), + 30_000, + ); + await driver.wait(selenium.until.elementIsEnabled(button), 30_000); + await button.click(); + }, + waitHeading: async (label) => { + await driver.wait( + async () => { + const headings = await driver.findElements( + selenium.By.css("h1,h2,h3"), + ); + for (const heading of headings) { + const text = await heading.getText(); + if (typeof label === "string" ? text === label : label.test(text)) + return true; + } + return false; + }, + 30_000, + `heading not shown: ${label}`, + ); + }, + waitUrl: (url) => driver.wait(selenium.until.urlIs(url), 30_000), + tabCount: async () => (await driver.getAllWindowHandles()).length, + balance: () => + driver.executeScript( + "return document.querySelector('[aria-label^=\"Available balance TESTKUDOS:\"]')?.getAttribute('aria-label') ?? null", + ), + setAutomaticOpening: () => + firefoxSetSettings(driver, { + autoOpen: true, + injectApi: false, + allowCallback: false, + hijackLinks: true, + }), + }); } finally { await driver.quit(); await fixture.close(); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-donau.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-donau.ts @@ -368,8 +368,6 @@ export async function runWalletWebUiPwaDonauTest(t: GlobalTestState) { await page.goto( `${pwaUrl}?talerUri=${encodeURIComponent(unpaidOrder.taler_pay_uri)}#/`, ); - await page.getByRole("heading", { name: "Wallet action" }).waitFor(); - await page.getByRole("button", { name: "Continue" }).click(); await page .getByRole("heading", { name: "Review payment", exact: true }) .waitFor(); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-errors.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-errors.ts @@ -91,7 +91,8 @@ export async function runWalletWebUiPwaErrorsTest(t: GlobalTestState) { await page.goto(`${pwaUrl}#/enter`); await page.getByLabel("Wallet link").fill(uri); await page.getByLabel("Wallet link").press("Enter"); - await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + if (uri !== PAY_URI) + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); } async function applyExperimentUri(page: typeof stage.page, uri: string) { @@ -113,7 +114,6 @@ export async function runWalletWebUiPwaErrorsTest(t: GlobalTestState) { friendlyMessage: RegExp, ) { await openWalletLink(page, PAY_URI); - await page.getByRole("button", { name: "Continue" }).click(); await page .locator('[role="alert"]') .filter({ hasText: friendlyMessage }) @@ -271,7 +271,6 @@ export async function runWalletWebUiPwaErrorsTest(t: GlobalTestState) { ), ); await openWalletLink(page, PAY_URI); - await page.getByRole("button", { name: "Continue" }).click(); const loading = page.getByText("Loading payment details…", { exact: true, }); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-payment-inputs.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-payment-inputs.ts @@ -50,7 +50,8 @@ interface PaymentScenario { async function openAction(page: Page, pwaUrl: string, uri: string) { await page.goto(`${pwaUrl}?talerUri=${encodeURIComponent(uri)}#/`); - await page.getByRole("button", { name: "Continue", exact: true }).click(); + if (!/^taler(?:\+http)?:\/\/pay\//.test(uri)) + await page.getByRole("button", { name: "Continue", exact: true }).click(); } /** Poll state rather than assuming that a UI update has already rendered. */ diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-withdrawal.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-withdrawal.ts @@ -355,7 +355,7 @@ async function runPwaWithdrawalTest( await input.fill("not a Taler link"); await page.getByRole("button", { name: "Open link" }).click(); await page.getByText("Enter a valid taler:// wallet link.").waitFor(); - await input.fill("taler://pay/merchant.example/order/session"); + await input.fill("taler://withdraw/bank.example/operation"); await input.press("Enter"); await page.getByRole("heading", { name: "Wallet action" }).waitFor(); await page.getByRole("button", { name: "Cancel" }).click(); @@ -718,8 +718,6 @@ async function runPwaWithdrawalTest( await page.goto( `${pwaUrl}?talerUri=${encodeURIComponent(unpaidAgeOrder.taler_pay_uri)}#/`, ); - await page.getByRole("heading", { name: "Wallet action" }).waitFor(); - await page.getByRole("button", { name: "Continue" }).click(); await page .getByRole("heading", { name: "Review payment", exact: true }) .waitFor(); @@ -760,8 +758,6 @@ async function runPwaWithdrawalTest( await page.goto( `${pwaUrl}?talerUri=${encodeURIComponent(unpaidChoiceOrder.taler_pay_uri)}#/`, ); - await page.getByRole("heading", { name: "Wallet action" }).waitFor(); - await page.getByRole("button", { name: "Continue" }).click(); await page .getByRole("heading", { name: "Review payment", exact: true }) .waitFor(); diff --git a/packages/taler-harness/src/integrationtests/wallet-web-ui-checkout-test-support.ts b/packages/taler-harness/src/integrationtests/wallet-web-ui-checkout-test-support.ts @@ -0,0 +1,182 @@ +/* + 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 assert from "node:assert/strict"; +import http from "node:http"; +import { + Amounts, + OrderVersion, + TalerMerchantInstanceHttpClient, + succeedOrThrow, +} from "@gnu-taler/taler-util"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import type { GlobalTestState } from "../harness/harness.js"; +import { + confirmWithdrawalWhenReady, + reserveTcpPort, +} from "./wallet-web-ui-test-support.js"; + +export interface CheckoutBrowser { + walletUrl: string; + goto(url: string): Promise<unknown>; + click(label: string): Promise<unknown>; + waitHeading(label: string | RegExp): Promise<unknown>; + waitUrl(url: string): Promise<unknown>; + tabCount(): Promise<number>; + balance(): Promise<string | null>; + setAutomaticOpening(): Promise<void>; +} + +/** Exercise the real payment flow with an order page that reloads after claim. */ +export async function exerciseBrowserCheckout( + t: GlobalTestState, + browser: CheckoutBrowser, +) { + const { bankClient, merchant, merchantAdminAccessToken } = + await createSimpleTestkudosEnvironmentV3(t); + const user = await bankClient.createRandomBankUser(); + bankClient.setAuth(user); + const withdrawal = await bankClient.createWithdrawalOperation( + user.username, + "TESTKUDOS:10", + ); + await browser.goto( + `${browser.walletUrl}?talerUri=${encodeURIComponent(withdrawal.taler_withdraw_uri)}#/`, + ); + await browser.click("Continue"); + await browser.click("Withdraw"); + await browser.waitHeading("Review withdrawal"); + await browser.click("Withdraw"); + await confirmWithdrawalWhenReady(() => + bankClient.confirmWithdrawalOperation(user.username, { + withdrawalOperationId: withdrawal.withdrawal_id, + }), + ); + await browser.goto(browser.walletUrl); + const deadline = Date.now() + 60_000; + while (true) { + const amount = (await browser.balance())?.match( + /TESTKUDOS:\d+(?:\.\d+)?/, + )?.[0]; + if (amount && Amounts.cmp(amount, "TESTKUDOS:3") >= 0) break; + if (Date.now() > deadline) + throw Error("browser wallet did not receive withdrawal"); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await browser.setAutomaticOpening(); + const client = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + const port = await reserveTcpPort(); + const base = `http://127.0.0.1:${port}`; + const fulfillment = `${base}/fulfilled`; + const created = succeedOrThrow( + await client.createOrder(merchantAdminAccessToken, { + order: { + version: OrderVersion.V1, + summary: "Browser checkout regression", + fulfillment_url: fulfillment, + choices: [{ amount: "TESTKUDOS:1" }], + }, + }), + ); + const unpaid = succeedOrThrow( + await client.getOrderDetails(merchantAdminAccessToken, created.order_id), + ); + assert.equal(unpaid.order_status, "unpaid"); + if (unpaid.order_status !== "unpaid") throw Error("expected unpaid order"); + let checkoutLoads = 0; + const server = http.createServer((request, response) => { + response.setHeader("cache-control", "no-store"); + if (request.url === "/status") { + void client + .getOrderDetails(merchantAdminAccessToken, created.order_id) + .then((result) => { + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + claimed: succeedOrThrow(result).order_status !== "unpaid", + }), + ); + }) + .catch(() => { + response.statusCode = 500; + response.end(); + }); + return; + } + response.setHeader("content-type", "text/html"); + if (request.url === "/fulfilled") { + response.end("<!doctype html><h1>Purchased article</h1>"); + } else if (request.url === "/checkout") { + checkoutLoads++; + response.end(`<!doctype html><html><head> +<meta name="taler-uri" content="${unpaid.taler_pay_uri}"> +<meta name="taler-support" content="uri"> +</head><body><h1>Merchant checkout</h1><script> +setInterval(async () => { const status = await (await fetch('/status')).json(); if (status.claimed) location.reload(); }, 250); +</script></body></html>`); + } else { + response.statusCode = 404; + response.end(); + } + }); + await new Promise<void>((resolve) => + server.listen(port, "127.0.0.1", resolve), + ); + try { + const tabs = await browser.tabCount(); + await browser.goto(`${base}/checkout`); + await browser.waitHeading("Review payment"); + assert.equal( + await browser.tabCount(), + tabs, + "metadata checkout must replace the merchant tab", + ); + await browser.click("Pay TESTKUDOS:1"); + await browser.waitUrl(fulfillment); + await browser.waitHeading("Purchased article"); + assert.equal( + await browser.tabCount(), + tabs, + "payment must not leave duplicate tabs", + ); + assert.equal( + checkoutLoads, + 1, + "merchant reload must not create another action", + ); + assert.equal( + succeedOrThrow( + await client.getOrderDetails( + merchantAdminAccessToken, + created.order_id, + ), + ).order_status, + "paid", + ); + + // Opening the same order again replays payment and returns without another Pay. + await browser.goto(`${base}/checkout`); + await browser.waitUrl(fulfillment); + assert.equal(await browser.tabCount(), tabs); + } finally { + server.closeAllConnections(); + await new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +} diff --git a/packages/wallet-webui/src/platform/adapters.ts b/packages/wallet-webui/src/platform/adapters.ts @@ -175,7 +175,9 @@ export function makeExtensionPlatform( throw new TypeError("refusing to open an unsafe external URL"); } if (target === "current-tab") { - await chrome.tabs.update({ url }); + const tab = await chrome.tabs.getCurrent(); + if (tab?.id === undefined) throw Error("wallet tab is unavailable"); + await chrome.tabs.update(tab.id, { url }); } else { await chrome.tabs.create({ url }); } diff --git a/packages/wallet-webui/src/platform/extension-background.ts b/packages/wallet-webui/src/platform/extension-background.ts @@ -1,4 +1,5 @@ /// <reference types="chrome" /> +import { Result, TalerUriAction, TalerUris } from "@gnu-taler/taler-util"; import { BROWSER_RPC_VERSION, unavailableCoreResponse, @@ -153,7 +154,14 @@ function trustedExtensionSender(sender: chrome.runtime.MessageSender): boolean { } } -async function queryCurrentPageAction(): Promise<string | undefined> { +function isPaymentUri(uri: string): boolean { + const parsed = TalerUris.parse(uri); + return Result.isOk(parsed) && parsed.value.type === TalerUriAction.Pay; +} + +async function queryCurrentPageAction(): Promise< + { uri: string; tabId: number } | undefined +> { const settings = await readBrowserIntegrationSettings(); if (settings.autoOpen) return undefined; const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); @@ -166,7 +174,7 @@ async function queryCurrentPageAction(): Promise<string | undefined> { )) as { uri?: unknown } | undefined; if (typeof response?.uri !== "string") return undefined; validateActionUri(response.uri); - return response.uri; + return { uri: response.uri, tabId: tab.id }; } catch { return undefined; } @@ -180,21 +188,24 @@ chrome.runtime.onMessage.addListener( return false; void (async () => { try { - const uri = await queryCurrentPageAction(); + const action = await queryCurrentPageAction(); if (message.operation === "inspect") { - sendResponse({ available: uri !== undefined }); + sendResponse({ available: action !== undefined }); return; } - if (!uri) { + if (!action) { sendResponse({ opened: false }); return; } - const entry = await inbox.put(uri, "metadata", { + const entry = await inbox.put(action.uri, "metadata", { fulfillmentTarget: "current-tab", }); - await chrome.tabs.create({ - url: chrome.runtime.getURL(`wallet.html#/action/${entry.id}`), - }); + const url = chrome.runtime.getURL(`wallet.html#/action/${entry.id}`); + if (isPaymentUri(action.uri)) { + await chrome.tabs.update(action.tabId, { url }); + } else { + await chrome.tabs.create({ url }); + } sendResponse({ opened: true }); } catch { sendResponse({ opened: false }); @@ -225,8 +236,9 @@ chrome.runtime.onMessage.addListener( `wallet.html#/action/${entry.id}`, ); if ( - message.source === "link" && - message.navigationTarget === "current-tab" + (message.source === "link" && + message.navigationTarget === "current-tab") || + (message.source === "metadata" && isPaymentUri(message.uri)) ) { await chrome.tabs.update(sender.tab!.id!, { url }); } else { diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -125,6 +125,7 @@ import { paymentReviewView, safeWebUrl, } from "./payment-model.js"; +import { usePaymentFulfillment } from "./payment-fulfillment.js"; import { depositKycAuthWireOptions, integratedWithdrawalView, @@ -3094,6 +3095,53 @@ function ActionRoute() { const [detail, setDetail] = useState<string>(); const [actionError, setActionError] = useState<ErrorPresentation>(); const [canRetry, setCanRetry] = useState(false); + const paymentRequest = useRef({ active: false, preparing: false }); + const preparePayment = useCallback( + async (action: NonNullable<typeof entry>) => { + const request = paymentRequest.current; + if (!request.active || request.preparing) return; + request.preparing = true; + setState("working"); + setActionError(undefined); + setCanRetry(false); + try { + const result = await progressRequest.callAndInvalidate( + WalletApiOperation.PreparePayForUriV2, + { talerPayUri: action.uri }, + ["transactions"], + ); + if (!request.active) return; + if (Result.isError(result)) { + setActionError( + walletCoreError( + result.detail, + /* Translators: wallet-core is the background wallet engine; keep the component name unchanged. */ i18n.str`Wallet-core could not prepare this payment.`, + ), + ); + setCanRetry(true); + setState("error"); + return; + } + navigate(`/pay/${result.value.transactionId}/${action.id}`); + } catch (error) { + if (!request.active) return; + if (isProgressRequestCancelled(error)) { + request.active = false; + void completeActionBestEffort(platform.actionInbox, action.id); + navigate("/"); + } else { + setActionError( + errorFromException(error, i18n.str`Action preparation failed`), + ); + setCanRetry(true); + setState("error"); + } + } finally { + request.preparing = false; + } + }, + [navigate, platform, progressRequest], + ); const parsed = useMemo( () => (entry ? TalerUris.parse(entry.uri) : undefined), [entry], @@ -3115,6 +3163,8 @@ function ActionRoute() { ); useEffect(() => { let active = true; + const request = { active: true, preparing: false }; + paymentRequest.current = request; setEntry(undefined); setState("loading"); setDetail(undefined); @@ -3125,7 +3175,17 @@ function ActionRoute() { .then((value) => { if (!active) return; setEntry(value); - setState(value ? "ready" : "missing"); + const uri = value && TalerUris.parse(value.uri); + if ( + value && + uri && + Result.isOk(uri) && + uri.value.type === TalerUriAction.Pay + ) { + void preparePayment(value); + } else { + setState(value ? "ready" : "missing"); + } }) .catch(() => { if (!active) return; @@ -3134,13 +3194,18 @@ function ActionRoute() { }); return () => { active = false; + request.active = false; }; - }, [params?.id, platform]); + }, [params?.id, platform, preparePayment]); const discard = () => { + paymentRequest.current.active = false; void completeActionBestEffort(platform.actionInbox, params?.id); navigate("/"); }; - const back = () => navigate("/"); + const back = () => { + paymentRequest.current.active = false; + navigate("/"); + }; const prepare = async () => { if (!entry || !parsed) return; setState("working"); @@ -3156,23 +3221,7 @@ function ActionRoute() { setState("error"); return; } else if (parsed.value.type === TalerUriAction.Pay) { - const result = await progressRequest.callAndInvalidate( - WalletApiOperation.PreparePayForUriV2, - { talerPayUri: entry.uri }, - ["transactions"], - ); - if (Result.isError(result)) { - setActionError( - walletCoreError( - result.detail, - /* Translators: wallet-core is the background wallet engine; keep the component name unchanged. */ i18n.str`Wallet-core could not prepare this payment.`, - ), - ); - setCanRetry(true); - setState("error"); - return; - } - navigate(`/pay/${result.value.transactionId}/${entry.id}`); + await preparePayment(entry); return; } else if (parsed.value.type === TalerUriAction.PayTemplate) { const checked = await progressRequest.callForResult( @@ -4090,27 +4139,15 @@ function PaymentRoute() { const [, navigate] = useLocation(); const transactionId = params?.transactionId ?? ""; const actionId = params?.actionId ?? ""; - const [fulfillmentTarget, setFulfillmentTarget] = - useState<ExternalNavigationTarget>(); + const { + fulfillmentTarget, + browserCheckout, + complete: completePayment, + } = usePaymentFulfillment(platform, actionId); const [actionError, setActionError] = useState<ErrorPresentation>(); const [resuming, setResuming] = useState(false); const [cancelling, setCancelling] = useState(false); const [reclaiming, setReclaiming] = useState(false); - useEffect(() => { - let active = true; - setFulfillmentTarget(undefined); - void platform.actionInbox.get(actionId).then( - (entry) => { - if (active) setFulfillmentTarget(entry?.fulfillmentTarget ?? "new-tab"); - }, - () => { - if (active) setFulfillmentTarget("new-tab"); - }, - ); - return () => { - active = false; - }; - }, [actionId, platform]); const query = useWalletQuery( connection, WalletApiOperation.GetTransactionById, @@ -4163,13 +4200,13 @@ function PaymentRoute() { : undefined; const paymentActionTransactionId = replayPayment?.transactionId ?? transactionId; - const replayDone = - repurchaseTransactionId !== undefined && - replayPayment?.txState.major === TransactionMajorState.Done; + const paymentDone = + payment !== undefined && + paymentResultState(payment, replayPayment) === "done"; + const fulfillmentUrl = payment?.contractTerms?.fulfillment_url; useEffect(() => { - if (!replayDone || fulfillmentTarget === undefined) return; - void completeActionBestEffort(platform.actionInbox, actionId); - }, [actionId, fulfillmentTarget, platform, replayDone]); + if (paymentDone) void completePayment(fulfillmentUrl); + }, [completePayment, fulfillmentUrl, paymentDone]); const waitingForContract = payment?.txState.minor === TransactionMinorState.ClaimProposal && payment.txState.working === true && @@ -4330,6 +4367,8 @@ function PaymentRoute() { transaction={transaction} actionId={actionId} fulfillmentTarget={fulfillmentTarget} + browserCheckout={Boolean(browserCheckout)} + onPaymentDone={completePayment} /> ); const resultState = paymentResultState(transaction, replayPayment); @@ -4395,7 +4434,10 @@ function PaymentDialog(props: { transaction: TransactionPayment; actionId: string; fulfillmentTarget: ExternalNavigationTarget; + browserCheckout: boolean; + onPaymentDone: (url?: string) => Promise<void>; }) { + const { browserCheckout, onPaymentDone } = props; const { connection, platform } = useServices(); const { language } = useLanguage(); const [, navigate] = useLocation(); @@ -4514,7 +4556,7 @@ function PaymentDialog(props: { } if (result.value.type === ConfirmPayResultType.Done) { setState("done"); - void completeActionBestEffort(platform.actionInbox, props.actionId); + void onPaymentDone(result.value.contractTerms.fulfillment_url); } else if (result.value.lastError) { setErrorRecovery( exchangeKeyRecoveryFromError(result.value.lastError), @@ -4528,7 +4570,8 @@ function PaymentDialog(props: { setState("paused"); } else { setState("pending"); - void completeActionBestEffort(platform.actionInbox, props.actionId); + if (!browserCheckout) + void completeActionBestEffort(platform.actionInbox, props.actionId); } } catch (cause) { setError( @@ -4543,6 +4586,8 @@ function PaymentDialog(props: { durability, platform, props.actionId, + browserCheckout, + onPaymentDone, props.transaction.transactionId, review, useDonau, diff --git a/packages/wallet-webui/src/routes/payment-fulfillment.ts b/packages/wallet-webui/src/routes/payment-fulfillment.ts @@ -0,0 +1,99 @@ +/* + 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along + with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ + +import { useCallback, useEffect, useState } from "preact/hooks"; +import { Result, TalerUriAction, TalerUris } from "@gnu-taler/taler-util"; +import type { + ActionEntry, + ExternalNavigationTarget, + PlatformAdapter, +} from "../api/contracts.js"; +import { completeActionBestEffort } from "../platform/action-inbox.js"; +import { safeWebUrl } from "./payment-model.js"; + +function isBrowserCheckout(platform: PlatformAdapter, entry?: ActionEntry) { + if (platform.target === "pwa" || !entry) return false; + const parsed = TalerUris.parse(entry.uri); + return ( + Result.isOk(parsed) && + parsed.value.type === TalerUriAction.Pay && + (entry.source === "link" || + entry.source === "metadata" || + (entry.source === "protocol" && + entry.fulfillmentTarget === "current-tab")) + ); +} + +/** Keep checkout context until payment succeeds, even when ConfirmPay is pending. */ +export function usePaymentFulfillment( + platform: PlatformAdapter, + actionId: string, +) { + const [context, setContext] = useState<{ + id: string; + platform: PlatformAdapter; + target: ExternalNavigationTarget; + browserCheckout: boolean; + active: boolean; + completed: boolean; + }>(); + useEffect(() => { + const current = { + id: actionId, + platform, + target: "new-tab" as ExternalNavigationTarget, + browserCheckout: false, + active: true, + completed: false, + }; + void (async () => { + try { + const entry = await platform.actionInbox.get(actionId); + current.target = entry?.fulfillmentTarget ?? "new-tab"; + current.browserCheckout = isBrowserCheckout(platform, entry); + } catch { + // Missing checkout context keeps completion manual. + } + if (current.active) setContext(current); + })(); + return () => { + current.active = false; + }; + }, [actionId, platform]); + const ready = + context?.id === actionId && context.platform === platform && context.active; + const complete = useCallback( + async (url?: string) => { + if (!ready || !context || context.completed) return; + context.completed = true; + await completeActionBestEffort(platform.actionInbox, actionId); + if (!context.active || !context.browserCheckout || !safeWebUrl(url)) + return; + try { + await platform.openExternal(url, context.target); + } catch { + // The success screen retains its manual fulfillment button. Do not loop + // on transaction notifications or rerenders if navigation is unavailable. + } + }, + [actionId, context, platform, ready], + ); + return { + fulfillmentTarget: ready ? context.target : undefined, + browserCheckout: ready && context.browserCheckout, + complete, + }; +} diff --git a/packages/wallet-webui/test/adapters.test.ts b/packages/wallet-webui/test/adapters.test.ts @@ -153,7 +153,11 @@ test("platform adapters reject unsafe external navigation", async () => { value: { tabs: { create: async ({ url }: { url: string }) => tabs.push(url), - update: async ({ url }: { url: string }) => updates.push(url), + getCurrent: async () => ({ id: 42 }), + update: async (id: number, { url }: { url: string }) => { + assert.equal(id, 42, "navigate the wallet tab, not the active tab"); + updates.push(url); + }, }, storage: { session: { get: async () => ({}), set: async () => {} } }, runtime: { @@ -174,6 +178,17 @@ test("platform adapters reject unsafe external navigation", async () => { ); assert.deepEqual(tabs, ["https://bank.example/transfer/2"]); assert.deepEqual(updates, ["https://merchant.example/fulfillment/2"]); + chrome.tabs.getCurrent = (async () => + undefined) as typeof chrome.tabs.getCurrent; + await assert.rejects( + () => + extension.openExternal( + "https://merchant.example/fulfillment/3", + "current-tab", + ), + /wallet tab is unavailable/, + ); + assert.deepEqual(updates, ["https://merchant.example/fulfillment/2"]); await window.happyDOM.abort(); }); diff --git a/packages/wallet-webui/test/payment-entry.test.tsx b/packages/wallet-webui/test/payment-entry.test.tsx @@ -0,0 +1,188 @@ +/* + 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along + with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { register } from "node:module"; +import { Window } from "happy-dom"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { + createDemoServices, + demoActionUris, +} from "../src/testing/demo-wallet.js"; +import type { ActionEntry } from "../src/api/contracts.js"; + +// Match the browser bundle's React alias when rendering SWR-backed routes. +register( + `data:text/javascript,${encodeURIComponent(` + export async function resolve(specifier, context, nextResolve) { + if (specifier === "react" || specifier === "use-sync-external-store/shim/index.js") return { url: ${JSON.stringify(import.meta.resolve("preact/compat"))}, shortCircuit: true }; + return nextResolve(specifier, context); + } +`)}`, + import.meta.url, +); + +async function harness( + source: ActionEntry["source"], + mode: "normal" | "failure" | "delayed" = "normal", +) { + const dom = new Window({ url: "https://wallet.example/" }); + for (const key of [ + "window", + "document", + "navigator", + "location", + "history", + "localStorage", + "sessionStorage", + "HTMLElement", + "Element", + "Node", + "Event", + "MutationObserver", + ] as const) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value: key === "window" ? dom : dom[key], + }); + } + for (const key of [ + "addEventListener", + "removeEventListener", + "dispatchEvent", + ] as const) + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value: dom[key].bind(dom), + }); + const { render, cleanup, act } = await import("@testing-library/preact"); + const { SWRConfig } = await import("swr"); + const { App } = await import("../src/routes/App.js"); + const services = createDemoServices(); + const entry = await services.platform.actionInbox.put( + demoActionUris.payment, + source, + ); + dom.location.hash = `#/action/${entry.id}`; + let prepares = 0; + let confirms = 0; + let release!: () => void; + const gate = new Promise<void>((resolve) => { + release = resolve; + }); + const original = services.connection.client.callForResult; + services.connection.client.callForResult = async (operation, request) => { + if (operation === WalletApiOperation.PreparePayForUriV2) { + prepares++; + if (mode === "failure" && prepares === 1) + throw Error("preparation unavailable"); + if (mode === "delayed") await gate; + } + if (operation === WalletApiOperation.ConfirmPay) confirms++; + return original(operation, request); + }; + const view = render( + <SWRConfig value={{ provider: () => new Map(), shouldRetryOnError: false }}> + <App {...services} /> + </SWRConfig>, + ); + const waitFor = async (check: () => void) => { + for (let n = 0; ; n++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + try { + check(); + return; + } catch (error) { + if (n >= 200) throw error; + } + } + }; + return { + view, + act, + waitFor, + release, + counts: () => ({ prepares, confirms }), + dom, + close: async () => { + cleanup(); + services.connection.close(); + await dom.happyDOM.abort(); + }, + }; +} + +for (const source of ["paste", "qr", "protocol"] as const) { + test(`PWA ${source} payment reaches review without action confirmation`, async () => { + const h = await harness(source); + try { + await h.waitFor(() => + assert(h.view.getByRole("heading", { name: "Review payment" })), + ); + assert.equal( + h.view.queryByRole("button", { name: "Continue", exact: true }), + null, + ); + assert(h.view.getByRole("button", { name: "Pay CHF:5.10" })); + assert.deepEqual(h.counts(), { prepares: 1, confirms: 0 }); + } finally { + await h.close(); + } + }); +} + +test("automatic payment preparation requires explicit retry after failure", async () => { + const h = await harness("protocol", "failure"); + try { + await h.waitFor(() => + assert(h.view.getByRole("button", { name: "Retry" })), + ); + assert.equal(h.counts().prepares, 1); + await h.act(() => h.view.getByRole("button", { name: "Retry" }).click()); + await h.waitFor(() => + assert(h.view.getByRole("heading", { name: "Review payment" })), + ); + assert.deepEqual(h.counts(), { prepares: 2, confirms: 0 }); + } finally { + await h.close(); + } +}); + +test("cancelled payment preparation cannot navigate back to review", async () => { + const h = await harness("protocol", "delayed"); + try { + await h.waitFor(() => assert.equal(h.counts().prepares, 1)); + await h.act(() => + h.view.getByRole("button", { name: "Cancel", exact: true }).click(), + ); + h.release(); + await h.waitFor(() => + assert(h.view.getByRole("heading", { name: "Balances" })), + ); + assert.equal( + h.view.queryByRole("heading", { name: "Review payment" }), + null, + ); + assert.deepEqual(h.counts(), { prepares: 1, confirms: 0 }); + } finally { + await h.close(); + } +}); diff --git a/packages/wallet-webui/test/payment-fulfillment.test.tsx b/packages/wallet-webui/test/payment-fulfillment.test.tsx @@ -0,0 +1,219 @@ +/* + 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along + with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { Window } from "happy-dom"; +import type { ActionEntry, WalletTarget } from "../src/api/contracts.js"; +import { + createDemoServices, + demoActionUris, +} from "../src/testing/demo-wallet.js"; +import { usePaymentFulfillment } from "../src/routes/payment-fulfillment.js"; + +async function harness( + target: WalletTarget, + source: ActionEntry["source"], + options: { + target?: "current-tab" | "new-tab"; + rejectNavigation?: boolean; + uri?: string; + } = {}, +) { + const dom = new Window({ url: "https://wallet.example/" }); + for (const key of [ + "window", + "document", + "navigator", + "HTMLElement", + "MutationObserver", + ] as const) { + Object.defineProperty(globalThis, key, { + configurable: true, + value: key === "window" ? dom : dom[key], + }); + } + const { render, act, cleanup } = await import("@testing-library/preact"); + const waitFor = async (check: () => void) => { + for (let attempt = 0; ; attempt++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + try { + check(); + return; + } catch (error) { + if (attempt >= 100) throw error; + } + } + }; + const platform = createDemoServices().platform; + Object.assign(platform, { target }); + const entry = await platform.actionInbox.put( + options.uri ?? demoActionUris.payment, + source, + { fulfillmentTarget: options.target ?? "current-tab" }, + ); + const navigations: string[] = []; + platform.openExternal = async (url, where) => { + assert.equal(where, "current-tab"); + navigations.push(url); + if (options.rejectNavigation) throw Error("navigation unavailable"); + }; + let flow!: ReturnType<typeof usePaymentFulfillment>; + function Harness(props: { id: string }) { + flow = usePaymentFulfillment(platform, props.id); + return <span>{flow.fulfillmentTarget ?? "loading"}</span>; + } + const view = render(<Harness id={entry.id} />); + await waitFor(() => assert.notEqual(flow.fulfillmentTarget, undefined)); + return { + platform, + entry, + navigations, + view, + Harness, + act, + waitFor, + flow: () => flow, + close: async () => { + cleanup(); + await dom.happyDOM.abort(); + }, + }; +} + +for (const target of ["chrome", "firefox"] as const) { + for (const source of ["link", "metadata", "protocol"] as const) { + test(`${target} ${source} checkout returns once after success`, async () => { + const h = await harness(target, source); + try { + assert.equal(h.flow().browserCheckout, true); + assert( + await h.platform.actionInbox.get(h.entry.id), + "pending checkout must retain context", + ); + const url = "https://merchant.example/fulfilled"; + await h.act(async () => { + await Promise.all([h.flow().complete(url), h.flow().complete(url)]); + }); + await h.act(async () => { + h.view.rerender(<h.Harness id={h.entry.id} />); + }); + await h.flow().complete(url); + assert.deepEqual(h.navigations, [url]); + assert.equal(await h.platform.actionInbox.get(h.entry.id), undefined); + } finally { + await h.close(); + } + }); + } +} + +for (const [target, source, where] of [ + ["pwa", "protocol", "current-tab"], + ["pwa", "paste", "new-tab"], + ["firefox", "paste", "current-tab"], + ["firefox", "qr", "current-tab"], + ["chrome", "protocol", "new-tab"], +] as const) { + test(`${target} ${source} ${where} keeps fulfillment manual`, async () => { + const h = await harness(target, source, { target: where }); + try { + assert.equal(h.flow().browserCheckout, false); + await h.flow().complete("https://merchant.example/fulfilled"); + assert.deepEqual(h.navigations, []); + } finally { + await h.close(); + } + }); +} + +for (const url of [ + undefined, + "javascript:alert(1)", + "taler://fulfillment-success/test", +]) { + test(`checkout without a safe fulfillment URL stays in the wallet: ${url}`, async () => { + const h = await harness("firefox", "metadata"); + try { + await h.flow().complete(url); + assert.deepEqual(h.navigations, []); + assert.equal(await h.platform.actionInbox.get(h.entry.id), undefined); + } finally { + await h.close(); + } + }); +} + +test("failed navigation is not retried on another completion notification", async () => { + const h = await harness("firefox", "metadata", { rejectNavigation: true }); + try { + await h.flow().complete("https://merchant.example/fulfilled"); + await h.flow().complete("https://merchant.example/fulfilled"); + assert.equal(h.navigations.length, 1); + } finally { + await h.close(); + } +}); + +test("a pending checkout retains browser context after remount", async () => { + const h = await harness("firefox", "metadata"); + try { + h.view.unmount(); + const { render } = await import("@testing-library/preact"); + render(<h.Harness id={h.entry.id} />); + await h.waitFor(() => assert.equal(h.flow().browserCheckout, true)); + await h.flow().complete("https://merchant.example/fulfilled"); + assert.equal(h.navigations.length, 1); + } finally { + await h.close(); + } +}); + +test("leaving checkout while cleanup is pending prevents navigation", async () => { + const h = await harness("firefox", "metadata"); + try { + let finish!: () => void; + h.platform.actionInbox.complete = () => + new Promise<void>((resolve) => { + finish = resolve; + }); + const completion = h.flow().complete("https://merchant.example/fulfilled"); + h.view.unmount(); + finish(); + await completion; + assert.deepEqual(h.navigations, []); + } finally { + await h.close(); + } +}); + +test("an expired or completed checkout does not automatically navigate on reopening", async () => { + const h = await harness("firefox", "metadata"); + try { + await h.platform.actionInbox.complete(h.entry.id); + h.view.unmount(); + const { render } = await import("@testing-library/preact"); + render(<h.Harness id={h.entry.id} />); + await h.waitFor(() => assert.equal(h.flow().fulfillmentTarget, "new-tab")); + assert.equal(h.flow().browserCheckout, false); + await h.flow().complete("https://merchant.example/fulfilled"); + assert.deepEqual(h.navigations, []); + } finally { + await h.close(); + } +});