diff --git a/e2e/README.md b/e2e/README.md index 3fd9a113c..e6d5d6a99 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -42,6 +42,51 @@ Every test run creates a unique user with a timestamped email address (`e2e-`. Multiple +instances can therefore run concurrently. + +`mopca.testnet.test` must resolve to loopback. The Playwright config maps it at +the browser level via a Chromium `--host-resolver-rules` launch arg, so **no +`/etc/hosts` change is required** to run the test. `mopca.testnet.test` is also +included in `pnpm local:hosts` (useful for manual `curl`/browser debugging +outside the test) — re-run that once (with sudo) if you want the host entry too: + +```bash +pnpm local:hosts +``` + +The Node-side Open Payments client must trust the self-signed cert. The `test` +scripts set `NODE_EXTRA_CA_CERTS=../local/config/certs/local.crt` for this; no +extra configuration is needed when running via `pnpm e2e:test`. + ## Running the tests **Prerequisites**: full local stack running (`pnpm local:setup && pnpm dev`). diff --git a/e2e/features/open-payments-purchase.feature b/e2e/features/open-payments-purchase.feature new file mode 100644 index 000000000..5d4889e8e --- /dev/null +++ b/e2e/features/open-payments-purchase.feature @@ -0,0 +1,16 @@ +Feature: Open Payments purchase via a Mock Open Payments Client App + + As a developer who made a recent change to the codebase + I want a Mock Open Payments Client App running in the e2e test environment + to be able to initiate, approve and verify an Open Payments workflow + so that I can easily verify the consistency of the Open Payments features. + + Scenario: Mock Open Payments Client App makes a simple purchase + Given an EUR merchant user with developer keys configured + And an EUR customer user with 100 EUR deposited into their account + And a running Mock Open Payments Client App initiated with the merchant keys + When the customer initiates a payment of 9.99 EUR through the MOPCA + Then the customer should be asked to verify the payment + And the customer should be informed about the success of the payment + When the merchant logs into their account and views transactions + Then a recent incoming transaction of 9.99 EUR should be visible diff --git a/e2e/features/steps/fixtures.ts b/e2e/features/steps/fixtures.ts index 375ca16a0..b0b67610b 100644 --- a/e2e/features/steps/fixtures.ts +++ b/e2e/features/steps/fixtures.ts @@ -1,8 +1,10 @@ import { createBdd, test as base } from 'playwright-bdd' +import type { BrowserContext, Page } from '@playwright/test' import { type Credentials, createUniqueCredentials } from '../../helpers/local-wallet' +import type { MopcaHandle } from '../../mopca/server' import { mkdir } from 'node:fs/promises' type FlowState = { @@ -28,7 +30,33 @@ type FlowState = { takeScreenshot: (name: string) => Promise } -export const test = base.extend<{ flow: FlowState }>({ +/** + * State for the Open Payments purchase scenario, which involves two independent + * users (merchant + customer) and an in-process MOPCA server. The customer uses + * the default `page`; the merchant runs in its own browser context so both + * sessions stay independent. Resources are torn down after the scenario. + */ +type OpPurchaseState = { + merchant: { + credentials?: Credentials + walletAddressUrl?: string + keyId?: string + publicKey?: string + privateKey?: string + context?: BrowserContext + page?: Page + } + customer: { + credentials?: Credentials + walletAddressUrl?: string + } + mopca?: MopcaHandle +} + +export const test = base.extend<{ + flow: FlowState + opPurchase: OpPurchaseState +}>({ flow: async ({ page }, use, testInfo) => { // Extract feature name from the generated test file path // e.g., ".features-gen/auth-signup-dashboard.feature.spec.js" → "auth-signup-dashboard" @@ -57,6 +85,21 @@ export const test = base.extend<{ flow: FlowState }>({ // eslint-disable-next-line react-hooks/rules-of-hooks await use(state) + }, + + // eslint-disable-next-line no-empty-pattern + opPurchase: async ({}, use) => { + const state: OpPurchaseState = { merchant: {}, customer: {} } + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use(state) + + if (state.mopca) { + await state.mopca.close().catch(() => {}) + } + if (state.merchant.context) { + await state.merchant.context.close().catch(() => {}) + } } }) diff --git a/e2e/features/steps/open-payments-purchase.steps.ts b/e2e/features/steps/open-payments-purchase.steps.ts new file mode 100644 index 000000000..c35b2aed4 --- /dev/null +++ b/e2e/features/steps/open-payments-purchase.steps.ts @@ -0,0 +1,268 @@ +import { expect, type Page } from '@playwright/test' +import { mkdir } from 'node:fs/promises' +import { setupVerifiedUser } from '../../helpers/local-wallet' +import { + depositIntoEurAccount, + generateDeveloperKey, + getWalletAddressKey, + getWalletAddressUrl +} from '../../helpers/op-merchant' +import { startMopca } from '../../mopca/server' +import { Given, Then, When } from './fixtures' + +const MERCHANT_KEY_NICKNAME = 'e2e' +const PURCHASE_AMOUNT = 9.99 +const EPSILON = 0.01 +const BASE_URL = process.env.TEST_BASE_URL || 'https://testnet.test' + +/** A screenshot helper bound to a specific page (used for the merchant context). */ +function screenshotter( + page: Page, + featureName: string, + prefix: string +): (name: string) => Promise { + let counter = 0 + return async (name: string) => { + counter += 1 + const dir = `test-results/${featureName}` + await mkdir(dir, { recursive: true }) + await page.screenshot({ + path: `${dir}/${prefix}-${String(counter).padStart(3, '0')}-${name}.png`, + fullPage: true + }) + } +} + +function parseAmount(text: string): number { + const parsed = Number.parseFloat( + text.replace(/,/g, '').replace(/[^0-9.]/g, '') + ) + if (Number.isNaN(parsed)) { + throw new Error(`Unable to parse amount from text: "${text}"`) + } + return parsed +} + +/** + * Poll a per-account transactions page (reloading between attempts) until an + * incoming (credit) row matching the amount appears. Open Payments settles via + * ILP webhooks, so the transaction may lag the payment completion. + */ +async function findIncomingTransaction( + page: Page, + amount: number +): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + if (attempt > 0) { + await page.waitForTimeout(3000) + await page.reload() + await expect( + page.getByRole('heading', { name: 'Transactions' }) + ).toBeVisible() + } + + const rows = page.locator('#transactionsList tbody tr.cursor-pointer') + const count = await rows.count() + for (let i = 0; i < count; i++) { + const amountText = ( + await rows.nth(i).locator('td').nth(2).innerText() + ).trim() + const isCredit = !amountText.startsWith('-') + if (isCredit && Math.abs(parseAmount(amountText) - amount) <= EPSILON) { + return true + } + } + } + return false +} + +Given( + 'an EUR merchant user with developer keys configured', + async ({ browser, opPurchase, flow }) => { + // The merchant runs in its own browser context so its session stays + // independent from the customer (who uses the default page). + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + baseURL: BASE_URL + }) + const page = await context.newPage() + opPurchase.merchant.context = context + opPurchase.merchant.page = page + + const shot = screenshotter(page, flow.featureName, 'merchant') + + const credentials = await setupVerifiedUser({ + page, + takeScreenshot: shot, + skipScreenshots: true + }) + opPurchase.merchant.credentials = credentials + + // Generate developer keys via the UI (nickname "e2e"); capture the private + // key from the dialog and read keyId/publicKey from the database. + const { privateKey } = await generateDeveloperKey({ + page, + nickname: MERCHANT_KEY_NICKNAME, + takeScreenshot: shot + }) + const { keyId, publicKey } = await getWalletAddressKey( + credentials.email, + MERCHANT_KEY_NICKNAME + ) + + opPurchase.merchant.privateKey = privateKey + opPurchase.merchant.keyId = keyId + opPurchase.merchant.publicKey = publicKey + opPurchase.merchant.walletAddressUrl = await getWalletAddressUrl( + credentials.email + ) + } +) + +Given( + 'an EUR customer user with 100 EUR deposited into their account', + async ({ page, opPurchase, flow }) => { + const credentials = await setupVerifiedUser({ + page, + takeScreenshot: flow.takeScreenshot, + skipScreenshots: true + }) + opPurchase.customer.credentials = credentials + + await depositIntoEurAccount({ + page, + amount: '100.00', + takeScreenshot: flow.takeScreenshot + }) + + opPurchase.customer.walletAddressUrl = await getWalletAddressUrl( + credentials.email + ) + } +) + +Given( + 'a running Mock Open Payments Client App initiated with the merchant keys', + async ({ opPurchase }) => { + const { merchant, customer } = opPurchase + if ( + !merchant.walletAddressUrl || + !merchant.keyId || + !merchant.privateKey || + !customer.walletAddressUrl + ) { + throw new Error('Merchant/customer setup did not complete.') + } + + // startMopca verifies it can reach the ASE with the merchant credentials. + opPurchase.mopca = await startMopca({ + merchant: { + walletAddressUrl: merchant.walletAddressUrl, + keyId: merchant.keyId, + privateKey: merchant.privateKey + }, + customerWalletAddressUrl: customer.walletAddressUrl, + item: { amount: PURCHASE_AMOUNT, description: 'testing stuff' } + }) + } +) + +When( + 'the customer initiates a payment of 9.99 EUR through the MOPCA', + async ({ page, opPurchase, flow }) => { + if (!opPurchase.mopca) { + throw new Error('MOPCA is not running.') + } + + await page.goto(opPurchase.mopca.url) + await expect(page.locator('#buyStuff')).toBeVisible() + await expect(page.locator('#itemPrice')).toHaveText('9.99 EUR') + await flow.takeScreenshot('mopca-storefront') + + // Submitting the purchase redirects the customer to the ASE consent screen. + await Promise.all([ + page.waitForURL(/\/grant-interactions/, { timeout: 60_000 }), + page.locator('#buyStuff').click() + ]) + await flow.takeScreenshot('grant-interaction-page') + } +) + +Then( + 'the customer should be asked to verify the payment', + async ({ page, flow }) => { + await expect(page.locator('button[aria-label="accept"]')).toBeVisible({ + timeout: 30_000 + }) + await expect(page.getByText('requesting access to')).toBeVisible() + await flow.takeScreenshot('grant-interaction-prompt') + } +) + +Then( + 'the customer should be informed about the success of the payment', + async ({ page, opPurchase, flow }) => { + // Approve the grant; the ASE redirects back to the MOPCA finish endpoint, + // which continues the grant, creates the outgoing payment and confirms the + // incoming payment was received. + await Promise.all([ + page.waitForURL(/mopca\.testnet\.test.*\/finish/, { timeout: 90_000 }), + page.locator('button[aria-label="accept"]').click() + ]) + + const result = page.locator('#paymentResult') + await expect(result).toBeVisible({ timeout: 90_000 }) + await flow.takeScreenshot('mopca-payment-result') + await expect(result).toHaveAttribute('data-status', 'completed') + + expect(opPurchase.mopca?.getResult()?.status).toBe('completed') + } +) + +When( + 'the merchant logs into their account and views transactions', + async ({ opPurchase, flow }) => { + const page = opPurchase.merchant.page + if (!page) { + throw new Error('Merchant page is not available.') + } + const shot = screenshotter(page, flow.featureName, 'merchant') + + await page.goto('/') + await expect(page.getByRole('heading', { name: 'Accounts' })).toBeVisible() + + const eurAccount = page + .locator('a[href*="/account/"]') + .filter({ hasText: 'EUR Account' }) + .first() + await expect(eurAccount).toBeVisible() + await eurAccount.click() + await expect(page).toHaveURL(/\/account\/.+/) + + const accountId = new URL(page.url()).pathname.split('/account/')[1] + await page.goto(`/transactions?accountId=${accountId}`) + await expect( + page.getByRole('heading', { name: 'Transactions' }) + ).toBeVisible() + await shot('merchant-transactions-page') + } +) + +Then( + 'a recent incoming transaction of 9.99 EUR should be visible', + async ({ opPurchase, flow }) => { + const page = opPurchase.merchant.page + if (!page) { + throw new Error('Merchant page is not available.') + } + const shot = screenshotter(page, flow.featureName, 'merchant') + + const found = await findIncomingTransaction(page, PURCHASE_AMOUNT) + await shot('merchant-incoming-transaction') + + expect( + found, + 'Expected an incoming 9.99 EUR transaction on the merchant EUR account' + ).toBe(true) + } +) diff --git a/e2e/helpers/op-merchant.ts b/e2e/helpers/op-merchant.ts new file mode 100644 index 000000000..dd3798300 --- /dev/null +++ b/e2e/helpers/op-merchant.ts @@ -0,0 +1,203 @@ +import { expect, type Page } from '@playwright/test' +import { Client } from 'pg' + +type ScreenshotFn = (name: string) => Promise + +export type DeveloperKey = { + keyId: string + publicKey: string + privateKey: string +} + +function dbConnectionString(dbUrl?: string): string { + return ( + dbUrl || + process.env.TEST_DB_URL || + 'postgres://wallet_backend:wallet_backend@localhost:15434/wallet_backend' + ) +} + +async function withDb( + fn: (client: Client) => Promise, + dbUrl?: string +): Promise { + const client = new Client({ connectionString: dbConnectionString(dbUrl) }) + await client.connect() + try { + return await fn(client) + } finally { + await client.end() + } +} + +/** + * Read the canonical (https) wallet address URL of a user's account for a given + * asset. A freshly provisioned user has a single default EUR account + wallet + * address, so this returns that by default. + */ +export async function getWalletAddressUrl( + email: string, + options?: { assetCode?: string; dbUrl?: string } +): Promise { + const assetCode = options?.assetCode ?? 'EUR' + const url = await withDb(async (client) => { + const { rows } = await client.query<{ url: string }>( + `SELECT wa.url + FROM "walletAddresses" wa + JOIN "accounts" a ON a.id = wa."accountId" + JOIN "users" u ON u.id = a."userId" + WHERE u.email = $1 + AND a."assetCode" = $2 + AND wa.active = true + ORDER BY wa."createdAt" ASC + LIMIT 1`, + [email, assetCode] + ) + return rows[0]?.url + }, options?.dbUrl) + + if (!url) { + throw new Error( + `No active ${assetCode} wallet address found for user "${email}".` + ) + } + return url +} + +/** + * Read the keyId (JWKS `kid`) and public key of a developer key by nickname. + * The private key is never persisted, so it must be captured at generation time + * (see generateDeveloperKey). + */ +export async function getWalletAddressKey( + email: string, + nickname: string, + options?: { dbUrl?: string } +): Promise<{ keyId: string; publicKey: string }> { + const key = await withDb(async (client) => { + const { rows } = await client.query<{ keyId: string; publicKey: string }>( + `SELECT k.id AS "keyId", k."publicKey" + FROM "walletAddressKeys" k + JOIN "walletAddresses" wa ON wa.id = k."walletAddressId" + JOIN "accounts" a ON a.id = wa."accountId" + JOIN "users" u ON u.id = a."userId" + WHERE u.email = $1 + AND k.nickname = $2 + ORDER BY k."createdAt" DESC + LIMIT 1`, + [email, nickname] + ) + return rows[0] + }, options?.dbUrl) + + if (!key) { + throw new Error(`No developer key "${nickname}" found for user "${email}".`) + } + return key +} + +/** + * Drive Settings → Developer Keys → Generate to create a developer key on the + * user's (default EUR) account. Returns the private key captured from the + * success dialog; combine with getWalletAddressKey to obtain the keyId. + * + * The user must be logged in. Leaves the developer-keys page open. + */ +export async function generateDeveloperKey(args: { + page: Page + nickname: string + accountName?: string + takeScreenshot: ScreenshotFn +}): Promise<{ privateKey: string }> { + const { page, nickname, takeScreenshot } = args + const accountName = args.accountName ?? 'EUR Account' + + await page.goto('/settings/developer-keys') + await expect( + page.getByRole('heading', { name: 'Developer Keys' }) + ).toBeVisible() + await takeScreenshot('dev-keys-page') + + // Expand the account disclosure to reveal its wallet addresses. + await page.getByRole('button', { name: `Account: ${accountName}` }).click() + + const generateButton = page + .locator('button[aria-label="generate keys"]') + .first() + await expect(generateButton).toBeVisible() + await generateButton.click() + await takeScreenshot('dev-keys-generate-dialog') + + await expect( + page.getByRole('heading', { name: 'Generate public & private key' }) + ).toBeVisible() + await page.locator('#nickname').fill(nickname) + + // The submit button reads "Generate keys" but carries aria-label="upload". + await page.locator('button[aria-label="upload"]').click() + + // The success dialog shows the private key (PEM) inside #copyKey. + const privateKeyLocator = page.locator('#copyKey code') + await expect(privateKeyLocator).toBeVisible({ timeout: 30_000 }) + const privateKey = (await privateKeyLocator.innerText()).trim() + await takeScreenshot('dev-keys-generated') + + if (!privateKey.includes('PRIVATE KEY')) { + throw new Error( + 'Failed to capture a PEM private key from the success dialog.' + ) + } + + await page.locator('#closeButtonSuccess').click() + await expect(privateKeyLocator).toBeHidden() + + return { privateKey } +} + +/** + * Deposit funds into the user's (default EUR) account via the wallet UI. + * Navigates to the account, opens the deposit dialog, funds it and waits for the + * deposit webhook to settle. + */ +export async function depositIntoEurAccount(args: { + page: Page + amount: string + takeScreenshot: ScreenshotFn +}): Promise { + const { page, amount, takeScreenshot } = args + + await page.goto('/') + await expect(page.getByRole('heading', { name: 'Accounts' })).toBeVisible() + + const eurAccount = page + .locator('a[href*="/account/"]') + .filter({ hasText: 'EUR Account' }) + .first() + await expect(eurAccount).toBeVisible() + await eurAccount.click() + await expect(page).toHaveURL(/\/account\/.+/) + + await expect(page.locator('#fund')).toBeVisible() + await page.locator('#fund').click() + + await expect(page.getByText('Deposit to Account')).toBeVisible() + await page.getByLabel('Amount').fill(amount) + await takeScreenshot('deposit-dialog-filled') + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/fund') && + response.request().method() === 'POST' && + response.status() >= 200 && + response.status() < 300 + ), + page.locator('button[aria-label="deposit"]').click() + ]) + + await expect(page.getByText('Deposit success')).toBeVisible() + await takeScreenshot('deposit-success') + + // Let the deposit webhook settle before the account is used for a payment. + await page.waitForTimeout(4000) +} diff --git a/e2e/mopca/server.ts b/e2e/mopca/server.ts new file mode 100644 index 000000000..e59245ba2 --- /dev/null +++ b/e2e/mopca/server.ts @@ -0,0 +1,628 @@ +import { createServer, type Server } from 'node:https' +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { createHash, randomUUID } from 'node:crypto' +import { + createAuthenticatedClient, + isPendingGrant, + OpenPaymentsClientError, + type AuthenticatedClient, + type Grant, + type GrantContinuation, + type IncomingPayment, + type PendingGrant, + type Quote, + type WalletAddress +} from '@interledger/open-payments' + +/** + * Mock Open Payments Client App (MOPCA). + * + * A minimal, in-process Open Payments *client* that plays the role of a + * merchant's checkout server. It is authenticated with the merchant's developer + * keys and: + * 1. creates an incoming payment on the merchant's wallet address (receiver), + * 2. quotes the purchase against the customer's wallet address (payer), + * 3. requests an interactive outgoing-payment grant and redirects the customer + * to the ASE (wallet) consent screen to approve it, + * 4. on return, verifies the interaction hash, continues the grant and creates + * the outgoing payment, then polls the incoming payment until the funds have + * been received. + * + * It is served over HTTPS with the local `*.testnet.test` wildcard certificate. + * Each instance binds an OS-assigned free port, so multiple instances can run + * concurrently — reachable at `https://mopca.testnet.test:`. + */ + +export const MOPCA_HOST = 'mopca.testnet.test' + +export interface MerchantCredentials { + /** The merchant's wallet address URL — the client's own identity and the receiver. */ + walletAddressUrl: string + /** The developer key id (the JWKS `kid`) registered on the merchant wallet address. */ + keyId: string + /** The merchant's private key in PKCS8 PEM format. */ + privateKey: string +} + +export interface MopcaItem { + amount: number + description: string +} + +export interface StartMopcaOptions { + merchant: MerchantCredentials + /** The customer's (payer's) wallet address URL. */ + customerWalletAddressUrl: string + item?: MopcaItem + /** Absolute path to the TLS cert/key. Defaults to the repo's local certs. */ + certPath?: string + keyPath?: string +} + +export interface MopcaHandle { + url: string + host: string + port: number + item: MopcaItem + /** The last completed/failed order result, populated after the finish redirect. */ + getResult(): OrderResult | undefined + close(): Promise +} + +export interface OrderResult { + orderId: string + status: 'completed' | 'failed' + message: string + /** The amount actually received on the merchant's incoming payment, if completed. */ + receivedAmount?: string +} + +interface PendingOrder { + orderId: string + quoteId: string + incomingPaymentUrl: string + continueUri: string + continueToken: string + continueWait: number + clientNonce: string + interactNonce: string + customerWalletAddressUrl: string + authServer: string +} + +const DEFAULT_ITEM: MopcaItem = { amount: 9.99, description: 'testing stuff' } +const DEFAULT_CERT = path.resolve( + __dirname, + '../../local/config/certs/local.crt' +) +const DEFAULT_KEY = path.resolve( + __dirname, + '../../local/config/certs/local.key' +) + +export async function startMopca( + options: StartMopcaOptions +): Promise { + const item = options.item ?? DEFAULT_ITEM + const flow = new OpenPaymentsFlow(options.merchant, item) + + // Building the client discovers the merchant wallet address & auth server and + // verifies we can talk to the ASE with the supplied credentials. + await flow.init() + + const orders = new Map() + let lastResult: OrderResult | undefined + + const server = createServer( + { + cert: readFileSync(options.certPath ?? DEFAULT_CERT), + key: readFileSync(options.keyPath ?? DEFAULT_KEY) + }, + (req, res) => { + handleRequest(req, res).catch((err) => { + console.error('[MOPCA] request error', err) + if (!res.headersSent) { + res.writeHead(500, { 'content-type': 'text/html' }) + } + res.end( + page('Error', `

Unexpected error: ${escapeHtml(String(err))}

`) + ) + }) + } + ) + + const baseUrl = await listen(server) + + async function handleRequest( + req: IncomingMessage, + res: ServerResponse + ): Promise { + const url = new URL(req.url ?? '/', baseUrl) + + if (req.method === 'GET' && url.pathname === '/') { + res.writeHead(200, { 'content-type': 'text/html' }) + res.end(renderStorefront(item)) + return + } + + if (req.method === 'POST' && url.pathname === '/buy') { + const orderId = randomUUID() + const finishUrl = `${baseUrl}/finish?orderId=${orderId}` + const { pending, redirect } = await flow.preparePayment({ + orderId, + customerWalletAddressUrl: options.customerWalletAddressUrl, + finishUrl + }) + orders.set(orderId, pending) + res.writeHead(302, { location: redirect }) + res.end() + return + } + + if (req.method === 'GET' && url.pathname === '/finish') { + const orderId = url.searchParams.get('orderId') ?? '' + const pending = orders.get(orderId) + if (!pending) { + res.writeHead(404, { 'content-type': 'text/html' }) + res.end( + page('Unknown order', '

Unknown order.

') + ) + return + } + + const result = url.searchParams.get('result') + if (result) { + lastResult = { + orderId, + status: 'failed', + message: `Grant not approved (${result}).` + } + orders.delete(orderId) + res.writeHead(200, { 'content-type': 'text/html' }) + res.end(renderResult(lastResult)) + return + } + + try { + const received = await flow.completePayment({ + pending, + interactRef: url.searchParams.get('interact_ref') ?? undefined, + hash: url.searchParams.get('hash') ?? undefined + }) + lastResult = { + orderId, + status: 'completed', + message: 'Payment successful.', + receivedAmount: received + } + } catch (err) { + const message = `Payment failed: ${describeError(err)}` + console.error('[MOPCA]', message) + lastResult = { orderId, status: 'failed', message } + } + orders.delete(orderId) + res.writeHead(lastResult.status === 'completed' ? 200 : 502, { + 'content-type': 'text/html' + }) + res.end(renderResult(lastResult)) + return + } + + res.writeHead(404, { 'content-type': 'text/html' }) + res.end(page('Not found', '

Not found.

')) + } + + const addr = server.address() as AddressInfo + return { + url: baseUrl, + host: MOPCA_HOST, + port: addr.port, + item, + getResult: () => lastResult, + close: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ) + } +} + +/** + * Listen on an OS-assigned free port bound to loopback and return the + * `mopca.testnet.test` base URL (which also resolves to loopback via /etc/hosts). + */ +function listen(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + resolve(`https://${MOPCA_HOST}:${port}`) + }) + }) +} + +class OpenPaymentsFlow { + private client!: AuthenticatedClient + private merchantWalletAddress!: WalletAddress + + constructor( + private merchant: MerchantCredentials, + private item: MopcaItem + ) {} + + async init(): Promise { + this.client = await createAuthenticatedClient({ + walletAddressUrl: this.merchant.walletAddressUrl, + keyId: this.merchant.keyId, + privateKey: this.merchant.privateKey, + // Local ASE responses can lag the published OpenAPI spec; skip strict + // response validation so the test focuses on the payment flow itself. + validateResponses: false + }) + + // Sanity check: prove we can reach the ASE with the merchant credentials. + this.merchantWalletAddress = await this.client.walletAddress.get({ + url: this.merchant.walletAddressUrl + }) + } + + async preparePayment(args: { + orderId: string + customerWalletAddressUrl: string + finishUrl: string + }): Promise<{ pending: PendingOrder; redirect: string }> { + const customerWalletAddress = await this.client.walletAddress.get({ + url: args.customerWalletAddressUrl + }) + + const incomingPayment = await this.createIncomingPayment( + this.merchantWalletAddress + ) + + const quote = await this.createQuote({ + walletAddress: customerWalletAddress, + receiver: incomingPayment.id + }) + + const clientNonce = randomUUID() + const grant = await this.createOutgoingPaymentGrant({ + walletAddress: customerWalletAddress, + debitAmount: quote.debitAmount, + receiver: incomingPayment.id, + nonce: clientNonce, + finishUrl: args.finishUrl + }) + + return { + pending: { + orderId: args.orderId, + quoteId: quote.id, + incomingPaymentUrl: incomingPayment.id, + continueUri: grant.continue.uri, + continueToken: grant.continue.access_token.value, + continueWait: grant.continue.wait ?? 0, + clientNonce, + interactNonce: grant.interact.finish, + customerWalletAddressUrl: customerWalletAddress.id, + authServer: customerWalletAddress.authServer + }, + redirect: grant.interact.redirect + } + } + + async completePayment(args: { + pending: PendingOrder + interactRef?: string + hash?: string + }): Promise { + const { pending, interactRef, hash } = args + this.verifyHash({ + interactRef, + receivedHash: hash, + clientNonce: pending.clientNonce, + interactNonce: pending.interactNonce, + authServer: pending.authServer + }) + + const continuation = await this.continueGrant({ + accessToken: pending.continueToken, + url: pending.continueUri, + interactRef, + wait: pending.continueWait + }).catch((err) => { + throw labelError('grant.continue', err) + }) + + const customerWalletAddress = await this.client.walletAddress.get({ + url: pending.customerWalletAddressUrl + }) + + await this.client.outgoingPayment + .create( + { + url: customerWalletAddress.resourceServer, + accessToken: continuation.accessToken + }, + { + walletAddress: pending.customerWalletAddressUrl, + quoteId: pending.quoteId, + metadata: { + description: `MOPCA purchase: ${this.item.description}`, + orderRef: pending.orderId + } + } + ) + .catch((err) => { + throw labelError('outgoingPayment.create', err) + }) + + return this.waitForIncomingPayment(pending.incomingPaymentUrl) + } + + private async createIncomingPayment( + walletAddress: WalletAddress + ): Promise { + const accessToken = await this.getIncomingPaymentToken( + walletAddress.authServer + ) + return this.client.incomingPayment.create( + { url: walletAddress.resourceServer, accessToken }, + { + expiresAt: new Date(Date.now() + 6000 * 60 * 5).toISOString(), + walletAddress: walletAddress.id, + incomingAmount: this.toAmount(walletAddress), + metadata: { description: `MOPCA purchase: ${this.item.description}` } + } + ) + } + + private async createQuote(args: { + walletAddress: WalletAddress + receiver: string + }): Promise { + const grant = await this.requestNonInteractiveGrant( + args.walletAddress.authServer, + { type: 'quote', actions: ['create', 'read'] } + ) + return this.client.quote.create( + { + url: args.walletAddress.resourceServer, + accessToken: grant.access_token.value + }, + { + method: 'ilp', + walletAddress: args.walletAddress.id, + receiver: args.receiver + } + ) + } + + private async createOutgoingPaymentGrant(args: { + walletAddress: WalletAddress + debitAmount: Quote['debitAmount'] + receiver: string + nonce: string + finishUrl: string + }): Promise { + const grant = await this.client.grant.request( + { url: args.walletAddress.authServer }, + { + access_token: { + access: [ + { + type: 'outgoing-payment', + actions: ['create', 'read', 'list'], + identifier: args.walletAddress.id, + limits: { debitAmount: args.debitAmount, receiver: args.receiver } + } + ] + }, + interact: { + start: ['redirect'], + finish: { method: 'redirect', uri: args.finishUrl, nonce: args.nonce } + } + } + ) + + if (!isPendingGrant(grant)) { + throw new Error('Expected an interactive outgoing-payment grant.') + } + return grant + } + + private async getIncomingPaymentToken(authServer: string): Promise { + const grant = await this.requestNonInteractiveGrant(authServer, { + type: 'incoming-payment', + actions: ['create', 'read', 'list'] + }) + return grant.access_token.value + } + + private async requestNonInteractiveGrant( + authServer: string, + access: { type: string; actions: string[] } + ): Promise }> { + const grant = await this.client.grant.request( + { url: authServer }, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore — `interact` is optional for non-interactive grants. + { access_token: { access: [access] } } + ) + if (isPendingGrant(grant) || !grant.access_token) { + throw new Error(`Expected a non-interactive ${access.type} grant.`) + } + return grant as Grant & { access_token: NonNullable } + } + + private async continueGrant(args: { + accessToken: string + url: string + interactRef?: string + wait?: number + }): Promise<{ accessToken: string; manageUrl: string }> { + if (!args.interactRef) { + throw new Error('Missing interact_ref on grant continuation.') + } + + // GNAP requires the client to wait for the grant's `wait` period before + // continuing; continuing early is rejected with `too_fast`. Wait, then + // retry a few times to absorb clock skew. + let waitMs = Math.max(args.wait ?? 0, 1) * 1000 + let lastError: unknown + for (let attempt = 0; attempt < 4; attempt++) { + await sleep(waitMs + 500) + try { + const continuation = await this.client.grant.continue( + { accessToken: args.accessToken, url: args.url }, + { interact_ref: args.interactRef } + ) + if (!isGrantWithToken(continuation)) { + throw new Error('Grant continuation did not return an access token.') + } + return { + accessToken: continuation.access_token.value, + manageUrl: continuation.access_token.manage + } + } catch (err) { + if (err instanceof OpenPaymentsClientError && err.code === 'too_fast') { + lastError = err + waitMs = Math.max(waitMs, 1000) + continue + } + throw err + } + } + throw lastError ?? new Error('Grant continuation timed out.') + } + + private verifyHash(args: { + interactRef?: string + receivedHash?: string + clientNonce: string + interactNonce: string + authServer: string + }): void { + if (!args.interactRef) throw new Error('Missing interact_ref.') + if (!args.receivedHash) throw new Error('Missing interaction hash.') + + const data = `${args.clientNonce}\n${args.interactNonce}\n${args.interactRef}\n${args.authServer}` + const hash = createHash('sha-256').update(data).digest('base64') + if (hash !== args.receivedHash) { + throw new Error( + `Interaction hash mismatch (received "${args.receivedHash}", computed "${hash}").` + ) + } + } + + /** Poll the merchant's incoming payment until the full amount has been received. */ + private async waitForIncomingPayment(url: string): Promise { + const accessToken = await this.getIncomingPaymentToken( + this.merchantWalletAddress.authServer + ) + const expected = this.toAmount(this.merchantWalletAddress).value + + for (let attempt = 0; attempt < 30; attempt++) { + const incomingPayment = await this.client.incomingPayment.get({ + url, + accessToken + }) + if (BigInt(incomingPayment.receivedAmount.value) >= BigInt(expected)) { + return incomingPayment.receivedAmount.value + } + await sleep(2000) + } + throw new Error( + `Incoming payment "${url}" did not receive the expected amount (${expected}).` + ) + } + + private toAmount(walletAddress: WalletAddress): { + value: string + assetCode: string + assetScale: number + } { + return { + value: Math.round( + this.item.amount * 10 ** walletAddress.assetScale + ).toString(), + assetCode: walletAddress.assetCode, + assetScale: walletAddress.assetScale + } + } +} + +function isGrantWithToken( + continuation: Grant | GrantContinuation +): continuation is Grant & { + access_token: NonNullable +} { + return (continuation as Grant).access_token !== undefined +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function labelError(step: string, err: unknown): Error { + return new Error(`[${step}] ${describeError(err)}`) +} + +function describeError(err: unknown): string { + if (err instanceof OpenPaymentsClientError) { + const parts = [err.description || err.message] + if (err.status) parts.push(`status=${err.status}`) + if (err.code) parts.push(`code=${err.code}`) + if (err.validationErrors?.length) { + parts.push(`validation=${err.validationErrors.join('; ')}`) + } + return `OpenPaymentsClientError(${parts.join(', ')})` + } + return String(err) +} + +function renderStorefront(item: MopcaItem): string { + return page( + 'MOPCA', + ` +

Mock Open Payments Client App

+

${escapeHtml(item.description)}

+

${item.amount.toFixed(2)} EUR

+
+ +
+ ` + ) +} + +function renderResult(result: OrderResult): string { + return page( + result.status === 'completed' ? 'Payment successful' : 'Payment failed', + ` +

${ + result.status === 'completed' ? 'Payment successful' : 'Payment failed' + }

+

${escapeHtml(result.message)}

+ ${ + result.receivedAmount + ? `

${escapeHtml(result.receivedAmount)}

` + : '' + } + ` + ) +} + +function page(title: string, body: string): string { + return `${escapeHtml( + title + )}${body}` +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} diff --git a/e2e/package.json b/e2e/package.json index 8bde15239..51d1d7ee4 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,14 +5,16 @@ "scripts": { "generate": "bddgen", "playwright:install": "playwright install", - "test": "bddgen && playwright test", - "test:headed": "bddgen && playwright test --headed", - "test:debug": "bddgen && playwright test --debug" + "test": "bddgen && cross-env NODE_EXTRA_CA_CERTS=../local/config/certs/local.crt playwright test", + "test:headed": "bddgen && cross-env NODE_EXTRA_CA_CERTS=../local/config/certs/local.crt playwright test --headed", + "test:debug": "bddgen && cross-env NODE_EXTRA_CA_CERTS=../local/config/certs/local.crt playwright test --debug" }, "devDependencies": { + "@interledger/open-payments": "^7.3.0", "@playwright/test": "^1.56.0", "@types/node": "^20.17.30", "@types/pg": "^8.11.14", + "cross-env": "^7.0.3", "dotenv": "^17.2.3", "pg": "^8.16.3", "playwright-bdd": "^8.0.0", diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index ce0314a1f..b4dd4a554 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -20,7 +20,9 @@ export default defineConfig({ retries: process.env.CI ? 1 : 0, workers: 1, reporter: [['list'], ['html', { open: 'never' }]], - timeout: 3 * 60 * 1000, + // Open Payments scenarios provision multiple users and poll for ILP settlement, + // so they need a longer ceiling than the simpler UI flows. + timeout: 5 * 60 * 1000, expect: { timeout: 15 * 1000 }, @@ -35,7 +37,13 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'], - viewport: { width: 1440, height: 1080 } + viewport: { width: 1440, height: 1080 }, + // Resolve the in-process MOPCA host to loopback at the browser level, so + // the Open Payments purchase test does not depend on an /etc/hosts entry + // for mopca.testnet.test (the MOPCA server binds 127.0.0.1 directly). + launchOptions: { + args: ['--host-resolver-rules=MAP mopca.testnet.test 127.0.0.1'] + } } } ] diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 38517371e..438b74698 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -12,6 +12,7 @@ "include": [ "helpers/**/*.ts", "features/**/*.ts", + "mopca/**/*.ts", "tests/**/*.ts", "playwright.config.ts" ] diff --git a/local/mockgatehub.yaml b/local/mockgatehub.yaml index 1bf4f9b10..63fb4d224 100644 --- a/local/mockgatehub.yaml +++ b/local/mockgatehub.yaml @@ -15,7 +15,7 @@ services: WEBHOOK_MIN_DELAY_SEC: ${WEBHOOK_MIN_DELAY_SEC:-0.05} depends_on: - redis - restart: always + restart: unless-stopped networks: - testnet extra_hosts: diff --git a/local/rafiki.yaml b/local/rafiki.yaml index c2c6be20f..62ab2d765 100644 --- a/local/rafiki.yaml +++ b/local/rafiki.yaml @@ -3,7 +3,7 @@ services: rafiki-auth: container_name: rafiki-auth-local image: ghcr.io/interledger/rafiki-auth:v2.2.0-beta - restart: always + restart: unless-stopped networks: - testnet ports: diff --git a/local/scripts/local-tools.sh b/local/scripts/local-tools.sh index d2b636ad5..8397d9806 100644 --- a/local/scripts/local-tools.sh +++ b/local/scripts/local-tools.sh @@ -23,6 +23,7 @@ HOSTS=( "api.boutique.test" "rafiki-card-service.testnet.test" "mockgatehub.testnet.test" + "mopca.testnet.test" ) # If .env does not exist then create it with touch diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd7dd6d59..62c9b2b69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: e2e: devDependencies: + '@interledger/open-payments': + specifier: ^7.3.0 + version: 7.3.0 '@playwright/test': specifier: ^1.56.0 version: 1.58.2 @@ -71,6 +74,9 @@ importers: '@types/pg': specifier: ^8.11.14 version: 8.20.0 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 dotenv: specifier: ^17.2.3 version: 17.4.2