diff --git a/.github/workflows/nightly-build.yaml b/.github/workflows/nightly-build.yaml index 7c6e5198..97a0e567 100644 --- a/.github/workflows/nightly-build.yaml +++ b/.github/workflows/nightly-build.yaml @@ -72,6 +72,10 @@ jobs: CHIMONEY_APP_WALLET_ADDRESS_URL: ${{ vars.E2E_CHIMONEY_APP_WALLET_ADDRESS_URL }} CHIMONEY_APP_USERNAME: ${{ vars.E2E_CHIMONEY_APP_USERNAME }} CHIMONEY_APP_PASSWORD: ${{ secrets.E2E_CHIMONEY_APP_PASSWORD }} + MMAON_WALLET_ORIGIN: ${{ vars.E2E_MMAON_WALLET_URL_ORIGIN }} + MMAON_WALLET_ADDRESS_URL: ${{ vars.E2E_MMAON_WALLET_ADDRESS_URL }} + MMAON_USERNAME: ${{ vars.E2E_MMAON_USERNAME }} + MMAON_PASSWORD: ${{ secrets.E2E_MMAON_PASSWORD }} INTERLEDGER_CARDS_WALLET_ADDRESS_URL: ${{ vars.E2E_INTERLEDGER_CARDS_WALLET_ADDRESS_URL }} INTERLEDGER_CARDS_ILP_DEV_WALLET_ADDRESS_URL: ${{ vars.E2E_INTERLEDGER_CARDS_ILP_DEV_WALLET_ADDRESS_URL }} INTERLEDGER_CARDS_USERNAME: ${{ secrets.E2E_INTERLEDGER_CARDS_USERNAME }} diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml index 2d8e6f3d..86c49565 100644 --- a/.github/workflows/tests-e2e.yml +++ b/.github/workflows/tests-e2e.yml @@ -76,6 +76,10 @@ jobs: CHIMONEY_APP_WALLET_ADDRESS_URL: ${{ vars.E2E_CHIMONEY_APP_WALLET_ADDRESS_URL }} CHIMONEY_APP_USERNAME: ${{ vars.E2E_CHIMONEY_APP_USERNAME }} CHIMONEY_APP_PASSWORD: ${{ secrets.E2E_CHIMONEY_APP_PASSWORD }} + MMAON_WALLET_ORIGIN: ${{ vars.E2E_MMAON_WALLET_URL_ORIGIN }} + MMAON_WALLET_ADDRESS_URL: ${{ vars.E2E_MMAON_WALLET_ADDRESS_URL }} + MMAON_USERNAME: ${{ vars.E2E_MMAON_USERNAME }} + MMAON_PASSWORD: ${{ secrets.E2E_MMAON_PASSWORD }} INTERLEDGER_CARDS_WALLET_ADDRESS_URL: ${{ vars.E2E_INTERLEDGER_CARDS_WALLET_ADDRESS_URL }} INTERLEDGER_CARDS_ILP_DEV_WALLET_ADDRESS_URL: ${{ vars.E2E_INTERLEDGER_CARDS_ILP_DEV_WALLET_ADDRESS_URL }} INTERLEDGER_CARDS_USERNAME: ${{ secrets.E2E_INTERLEDGER_CARDS_USERNAME }} diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index dcf55563..83e3ac56 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -4,6 +4,7 @@ Fynbos Rafiki Chimoney GateHub +MMAON SPSP webextension diff --git a/src/background/services/keyAutoAdd.ts b/src/background/services/keyAutoAdd.ts index e618da9a..c19b9f15 100644 --- a/src/background/services/keyAutoAdd.ts +++ b/src/background/services/keyAutoAdd.ts @@ -9,8 +9,7 @@ import { } from '@/shared/helpers'; import { createTab } from '@/background/utils'; import type { Browser, Runtime, Scripting } from 'webextension-polyfill'; -import type { WalletAddress } from '@interledger/open-payments'; -import type { TabId } from '@/shared/types'; +import type { TabId, WalletInfo } from '@/shared/types'; import type { Cradle } from '@/background/container'; import type { BeginPayload, @@ -48,7 +47,7 @@ export class KeyAutoAddService { } async addPublicKeyToWallet( - walletAddress: WalletAddress, + walletAddress: WalletInfo, onTabOpen: (tabId: TabId) => void, ) { const keyAddUrl = walletAddressToProvider(walletAddress); @@ -201,7 +200,7 @@ export class KeyAutoAddService { })); } - static supports(walletAddress: WalletAddress): boolean { + static supports(walletAddress: WalletInfo): boolean { try { walletAddressToProvider(walletAddress); return true; @@ -271,9 +270,34 @@ const CONTENT_SCRIPTS: Scripting.RegisteredContentScript[] = [ js: ['content/keyAutoAdd/gatehub.js'], persistAcrossSessions: false, }, + { + id: 'keyAutoAdd/mmaon/sandbox', + matches: ['https://staging.mmaon.com/*'], + js: ['content/keyAutoAdd/mmaon.js'], + persistAcrossSessions: false, + }, + { + id: 'keyAutoAdd/mmaon/prod', + matches: ['https://www.mmaon.com/*'], + js: ['content/keyAutoAdd/mmaon.js'], + persistAcrossSessions: false, + }, ]; -function walletAddressToProvider(walletAddress: WalletAddress): string { +function walletAddressToProvider(walletAddress: WalletInfo): string { + if (walletAddress.url) { + // Some wallet URLs have redirects to other "managing wallets" and need some + // special handling. + const { host } = new URL(walletAddress.url); + switch (host) { + case 'ilp-staging.mmaon.com': + return 'https://staging.mmaon.com/wallet/dashboard'; + case 'ilp.mmaon.on': + return 'https://www.mmaon.com/wallet/dashboard'; + // case 'ilp.dev' is handled normally as ilp.interledger.cards below + } + } + const { host } = new URL(walletAddress.id); switch (host) { case 'ilp.interledger-test.dev': @@ -293,7 +317,7 @@ function walletAddressToProvider(walletAddress: WalletAddress): string { return 'https://wallet.sandbox.gatehub.net/#/wallets/'; case 'ilp.gatehub.net': return 'https://wallet.gatehub.net/#/wallets/'; - default: - throw new ErrorWithKey('connectWalletKeyService_error_notImplemented'); } + + throw new ErrorWithKey('connectWalletKeyService_error_notImplemented'); } diff --git a/src/background/services/wallet.ts b/src/background/services/wallet.ts index e8baa701..c82ff7b8 100644 --- a/src/background/services/wallet.ts +++ b/src/background/services/wallet.ts @@ -27,8 +27,7 @@ import { isInvalidClientError } from '@/background/services/openPayments'; import { APP_URL } from '@/background/constants'; import { bytesToHex } from '@noble/hashes/utils'; import type { Cradle } from '@/background/container'; -import type { TabId } from '@/shared/types'; -import type { WalletAddress } from '@interledger/open-payments'; +import type { TabId, WalletInfo } from '@/shared/types'; import type { Browser, Tabs } from 'webextension-polyfill'; export class WalletService { @@ -409,7 +408,7 @@ export class WalletService { * through the wallet's dashboard. */ private async addPublicKeyToWallet( - walletAddress: WalletAddress, + walletAddress: WalletInfo, onTabOpen: (tabId: TabId) => void, ) { const keyAutoAdd = new KeyAutoAddService({ @@ -448,7 +447,7 @@ export class WalletService { } } - private async retryAddPublicKeyToWallet(walletAddress: WalletAddress) { + private async retryAddPublicKeyToWallet(walletAddress: WalletInfo) { let tabId: TabId | undefined; try { await this.addPublicKeyToWallet(walletAddress, (openedTabId) => { diff --git a/src/background/utils.ts b/src/background/utils.ts index 57b41c12..9fc07477 100644 --- a/src/background/utils.ts +++ b/src/background/utils.ts @@ -87,6 +87,11 @@ export const getExchangeRates = memoize( throw new Error('Invalid rates format'); } + // MMAON rate is not listed at EXCHANGE_RATES_URL. Hardcode it here until + // it's either added to the list or we switch to the preferred solution: + // https://github.com/interledger/web-monetization-extension/issues/977 + rates.rates.MMAON ??= 20; // 20 USD = 1 MMAON + return rates; }, { maxAge: 15 * 60 * 1000, mechanism: 'stale-while-revalidate' }, @@ -146,6 +151,14 @@ export const getBudgetRecommendationsData = memoize( throw new Error('Failed to fetch budget recommendations data.'); } const data: BudgetRecommendationsDataSchema = await response.json(); + + // MMAON rate is not listed at BUDGET_RECOMMENDATIONS_URL. Hardcode it here + // until it's added to the db. + data.MMAON = { + budget: { default: 60, max: 100 }, + hourly: { default: 20, max: 30 }, + }; + return data; }, { maxAge: 30 * 60 * 1000, mechanism: 'stale-while-revalidate' }, diff --git a/src/content/keyAutoAdd/mmaon.ts b/src/content/keyAutoAdd/mmaon.ts new file mode 100644 index 00000000..1343a82e --- /dev/null +++ b/src/content/keyAutoAdd/mmaon.ts @@ -0,0 +1,90 @@ +// cSpell:ignore nextjs +import { errorWithKey, ErrorWithKey, sleep } from '@/shared/helpers'; +import { + KeyAutoAdd, + LOGIN_WAIT_TIMEOUT, + type StepRun as Run, +} from './lib/keyAutoAdd'; +import { isTimedOut, waitForURL } from './lib/helpers'; +import { walletAddressUrlToId } from './lib/helpers/gatehub'; + +// #region: Steps + +const waitForLogin: Run = async ( + { keyAddUrl }, + { skip, setNotificationSize }, +) => { + await sleep(500); + let alreadyLoggedIn = window.location.href.startsWith(keyAddUrl); + if (!alreadyLoggedIn) setNotificationSize('notification'); + try { + alreadyLoggedIn ||= await waitForURL( + (url) => (url.origin + url.pathname).startsWith(keyAddUrl), + { timeout: LOGIN_WAIT_TIMEOUT }, + ); + setNotificationSize('fullscreen'); + } catch (error) { + if (isTimedOut(error)) { + throw new ErrorWithKey('connectWalletKeyService_error_timeoutLogin'); + } + throw new Error(error); + } + + if (alreadyLoggedIn) { + skip(errorWithKey('connectWalletKeyService_error_skipAlreadyLoggedIn')); + } +}; + +const findWallet: Run = async ( + { walletAddressUrl }, + { setNotificationSize }, +) => { + setNotificationSize('fullscreen'); + const accountAddress = walletAddressUrlToId(walletAddressUrl); + + const res = await fetch('/api/gatehub/wallet', { credentials: 'include' }); + if (!res.ok) { + throw new Error('Failed to get wallet details'); + } + const accountInfo: { address: string } = await res.json(); + + if (accountInfo.address !== accountAddress) { + throw new ErrorWithKey('connectWalletKeyService_error_accountNotFound'); + } +}; + +const addKey: Run = async ({ publicKey, nickName }) => { + const res = await fetch('/api/open-payments/upload-keys', { + method: 'POST', + body: JSON.stringify({ + base64Key: publicKey, + nickname: nickName, + }), + headers: { 'content-type': 'application/json' }, + credentials: 'include', + }).catch((error) => { + return Response.json(null, { status: 599, statusText: error.message }); + }); + + if (!res.ok) { + throw new Error(`Failed to upload public key (${res.statusText})`); + } + const data = await res.json().catch(() => null); + if (!data?.keyId) { + // Note: `keyId` is used for revoking keys; it's not same as JWK's `kid` + throw new Error(`Failed to upload public key (${await res.text()})`); + } +}; +// #endregion + +// #region: Main +new KeyAutoAdd([ + { + name: 'Waiting for you to login', + run: waitForLogin, + maxDuration: LOGIN_WAIT_TIMEOUT, + }, + { name: 'Finding wallet', run: findWallet }, + { name: 'Adding key', run: addKey }, +]).init(); +// #endregion diff --git a/tests/e2e/.env.example b/tests/e2e/.env.example index c07b4981..0a9ad1d4 100644 --- a/tests/e2e/.env.example +++ b/tests/e2e/.env.example @@ -31,6 +31,12 @@ CHIMONEY_WALLET_ADDRESS_URL= CHIMONEY_USERNAME= CHIMONEY_PASSWORD= +# MMAON specific tests, using MMAON sandbox +MMAON_WALLET_ORIGIN= +MMAON_WALLET_ADDRESS_URL= +MMAON_USERNAME= +MMAON_PASSWORD= + # Interledger.cards specific tests (optional) INTERLEDGER_CARDS_USERNAME= INTERLEDGER_CARDS_PASSWORD= diff --git a/tests/e2e/connectAutoKeyMmaon.spec.ts b/tests/e2e/connectAutoKeyMmaon.spec.ts new file mode 100644 index 00000000..afb7f811 --- /dev/null +++ b/tests/e2e/connectAutoKeyMmaon.spec.ts @@ -0,0 +1,139 @@ +import { test, expect } from './fixtures/base'; +import { withResolvers, getJWKS } from '@/shared/helpers'; +import { fillPopup } from './pages/popup'; +import { URLS, login, revokeKey } from './helpers/mmaon'; +import { getStorage } from './fixtures/helpers'; + +test('Connect to MMAON wallet with automatic key addition when not logged-in to wallet', async ({ + page, + popup, + context, + background, + i18n, +}) => { + const username = process.env.MMAON_USERNAME!; + const password = process.env.MMAON_PASSWORD!; + const walletAddressUrl = process.env.MMAON_WALLET_ADDRESS_URL!; + const walletOrigin = process.env.MMAON_WALLET_ORIGIN!; + + test.skip( + !username || !password || !walletAddressUrl || !walletOrigin, + 'Missing credentials', + ); + + const { keyId: kid } = await getStorage(background, ['keyId']); + + const connectButton = await test.step('fill popup', async () => { + const connectButton = await fillPopup(popup, i18n, { + walletAddressUrl, + amount: '100', + recurring: false, + }); + return connectButton; + }); + + await test.step('ensure not logged in', async () => { + await context.clearCookies(); + + await page.goto(URLS.keyPage); + await page.waitForURL((url) => url.href.startsWith(URLS.login)); + await expect(page).toHaveURL((url) => url.href.startsWith(URLS.loginFull)); + await page.close(); + }); + + await test.step('ensure key not already added', async () => { + const jwksBefore = await getJWKS(walletAddressUrl); + expect(jwksBefore.keys.length).toBeGreaterThanOrEqual(0); + expect(jwksBefore.keys.find((key) => key.kid === kid)).toBeUndefined(); + }); + + await test.step('asks for key-add consent', async () => { + await connectButton.click(); + await popup.waitForSelector( + `[data-testid="connect-wallet-auto-key-consent"]`, + ); + + await popup + .getByRole('button', { + name: i18n.getMessage('connectWalletKeyService_label_consentAccept'), + }) + .click(); + }); + + page = await test.step('shows login page', async () => { + const openedPage = await context.waitForEvent('page', (page) => + page.url().startsWith(walletOrigin), + ); + await openedPage.waitForURL((url) => url.href.startsWith(URLS.loginFull)); + await login(openedPage, { username, password }); + await openedPage.waitForURL((url) => url.href.startsWith(URLS.keyPage)); + return openedPage; + }); + + const revokeInfo = await test.step('adds key to wallet', async () => { + const { resolve, reject, promise } = withResolvers<{ keyId: string }>(); + page.on('requestfinished', async function intercept(req) { + if (req.serviceWorker()) return; + if (req.method() !== 'POST') return; + const url = new URL(req.url()); + const { origin, pathname: p } = url; + if (origin !== walletOrigin) return; + + if (p.startsWith('/api/open-payments/upload-keys')) { + page.off('requestfinished', intercept); + + const res = await req.response(); + if (!res) { + return reject('no response from /upload-keys API'); + } + if (!res.ok) { + return reject(`Failed to upload public key (${res.statusText})`); + } + + const json = await res.json(); + resolve(json); + } + }); + + await expect(promise).resolves.toMatchObject({ + keyId: expect.any(String), + }); + + const jwks = await getJWKS(walletAddressUrl); + expect(jwks.keys.length).toBeGreaterThan(0); + const key = jwks.keys.find((key) => key.kid === kid); + expect(key).toMatchObject({ kid }); + + return await promise; + }); + + /* // TODO https://github.com/interledger/web-monetization-extension/issues/1050 + await test.step('shows connect consent page', async () => { + await waitForGrantConsentPage(page); + await expect( + page.getByRole('button', { name: 'Accept', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Decline', exact: true }), + ).toBeVisible(); + }); + + await test.step('connects', async () => { + await page.getByRole('button', { name: 'Accept', exact: true }).click(); + await waitForWelcomePage(page); + await expect(background).toHaveStorage({ connected: true }); + }); + + await test.step('cleanup: disconnect wallet', async () => { + await disconnectWallet(popup); + }); + */ + + await test.step('cleanup: revoke key', async () => { + const res = await revokeKey(page, { keyId: revokeInfo.keyId }); + expect.soft(res).toMatchObject({ success: true }); + + const { keys } = await getJWKS(walletAddressUrl); + expect(keys.find((key) => key.kid === kid)).toBeUndefined(); + }); +}); diff --git a/tests/e2e/env.d.ts b/tests/e2e/env.d.ts index bf7ef4d3..d816b8da 100644 --- a/tests/e2e/env.d.ts +++ b/tests/e2e/env.d.ts @@ -57,6 +57,16 @@ interface TestEnvVars { /** Login password for Chimoney app wallet @secret */ CHIMONEY_APP_PASSWORD: string | undefined; + // If either of following is not provided, relevant tests will be skipped. + /** MMAON wallet address (used for MMAON specific tests only) */ + MMAON_WALLET_ADDRESS_URL: string | undefined; + /** MMAON wallet URL origin (without trailing /) */ + MMAON_WALLET_ORIGIN: string | undefined; + /** Login email for MMAON wallet */ + MMAON_USERNAME: string | undefined; + /** Login password for MMAON wallet @secret */ + MMAON_PASSWORD: string | undefined; + // If variables in following group are not provided, tests will not be run // for `interledger.cards` wallet. You must provide wallet addresses in at // least one format - interledger.cards & ilp.dev. diff --git a/tests/e2e/helpers/mmaon.ts b/tests/e2e/helpers/mmaon.ts new file mode 100644 index 00000000..44d33987 --- /dev/null +++ b/tests/e2e/helpers/mmaon.ts @@ -0,0 +1,45 @@ +import type { Page } from '@playwright/test'; + +export const URLS = { + login: `${process.env.MMAON_WALLET_ORIGIN}/auth/login`, + get loginFull() { + const url = new URL('/auth/login', process.env.MMAON_WALLET_ORIGIN!); + url.searchParams.set('callbackUrl', this.keyPage); + return url.href; + }, + keyPage: `${process.env.MMAON_WALLET_ORIGIN}/wallet/dashboard`, +}; + +export async function revokeKey(page: Page, revokeInfo: { keyId: string }) { + await page.goto(URLS.keyPage); + + return await page.evaluate(async (revokeInfo) => { + const res = await fetch('/api/open-payments/revoke-keys', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ keyId: revokeInfo.keyId }), + }); + return await res.json(); + }, revokeInfo); +} + +export async function waitForGrantConsentPage(page: Page) { + await page.waitForURL((url) => { + return ( + url.pathname.startsWith('/consent') && + url.searchParams.has('interactId') && + url.searchParams.has('nonce') && + url.searchParams.has('clientUri') + ); + }); +} + +export async function login( + page: Page, + { username, password }: { username: string; password: string }, +) { + await page.getByLabel('Email Address').fill(username); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Log On', exact: true }).click(); +}