From ea33b28977343980a759b9c22496d2d0ee7a6432 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 14 Oct 2024 19:12:00 +0530 Subject: [PATCH] feat: automatically add public key for Fynbos wallets (#643) --- .github/workflows/nightly-build.yaml | 3 + .github/workflows/tests-e2e.yml | 3 + docs/TESTING.md | 21 ++-- esbuild/config.ts | 16 ++- src/background/services/keyAutoAdd.ts | 6 +- src/content/keyAutoAdd/fynbos.ts | 124 ++++++++++++++++++ src/manifest.json | 5 + tests/e2e/.env.example | 7 ++ tests/e2e/connectAutoKeyFynbos.spec.ts | 168 +++++++++++++++++++++++++ tests/e2e/env.d.ts | 7 ++ tests/e2e/helpers/fynbos.ts | 66 ++++++++++ 11 files changed, 411 insertions(+), 15 deletions(-) create mode 100644 src/content/keyAutoAdd/fynbos.ts create mode 100644 tests/e2e/connectAutoKeyFynbos.spec.ts create mode 100644 tests/e2e/helpers/fynbos.ts diff --git a/.github/workflows/nightly-build.yaml b/.github/workflows/nightly-build.yaml index a34fb084..01a1678b 100644 --- a/.github/workflows/nightly-build.yaml +++ b/.github/workflows/nightly-build.yaml @@ -56,6 +56,9 @@ jobs: TEST_WALLET_KEY_ID: ${{ vars.E2E_CONNECT_KEY_ID }} TEST_WALLET_PUBLIC_KEY: ${{ secrets.E2E_CONNECT_PUBLIC_KEY }} TEST_WALLET_PRIVATE_KEY: ${{ secrets.E2E_CONNECT_PRIVATE_KEY }} + FYNBOS_WALLET_ADDRESS_URL: ${{ vars.E2E_FYNBOS_WALLET_ADDRESS_URL }} + FYNBOS_USERNAME: ${{ vars.E2E_FYNBOS_USERNAME }} + FYNBOS_PASSWORD: ${{ secrets.E2E_FYNBOS_PASSWORD }} - name: Encrypt report shell: bash diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml index 2f7a249d..d880cfe2 100644 --- a/.github/workflows/tests-e2e.yml +++ b/.github/workflows/tests-e2e.yml @@ -54,6 +54,9 @@ jobs: TEST_WALLET_KEY_ID: ${{ vars.E2E_CONNECT_KEY_ID }} TEST_WALLET_PUBLIC_KEY: ${{ secrets.E2E_CONNECT_PUBLIC_KEY }} TEST_WALLET_PRIVATE_KEY: ${{ secrets.E2E_CONNECT_PRIVATE_KEY }} + FYNBOS_WALLET_ADDRESS_URL: ${{ vars.E2E_FYNBOS_WALLET_ADDRESS_URL }} + FYNBOS_USERNAME: ${{ vars.E2E_FYNBOS_USERNAME }} + FYNBOS_PASSWORD: ${{ secrets.E2E_FYNBOS_PASSWORD }} - name: Encrypt report shell: bash diff --git a/docs/TESTING.md b/docs/TESTING.md index de1f9852..3758433c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -15,15 +15,18 @@ Make sure you run `pnpm build chrome` before running tests. 1. Copy `tests/e2e/.env.example` to `tests/e2e/.env` 2. Update `tests/e2e/.env` with your secrets. -| Environment Variable | Description | Secret? | -| ------------------------- | ----------------------------------------------------------- | ------- | -| `TEST_WALLET_ORIGIN` | URL origin of the test wallet (e.g. https://rafiki.money) | - | -| `TEST_WALLET_USERNAME` | -- Login email for the test wallet | - | -| `TEST_WALLET_PASSWORD` | -- Login password for the test wallet | Yes | -| `TEST_WALLET_ADDRESS_URL` | Your wallet address URL that will be connected to extension | - | -| `TEST_WALLET_KEY_ID` | ID of the key that will be connected to extension (UUID v4) | - | -| `TEST_WALLET_PRIVATE_KEY` | Private key (hex-encoded Ed25519 private key) | Yes | -| `TEST_WALLET_PUBLIC_KEY` | Public key (base64-encoded Ed25519 public key) | - | +| Environment Variable | Description | Secret? | Optional? | +| --------------------------- | ----------------------------------------------------------- | ------- | --------- | +| `TEST_WALLET_ORIGIN` | URL origin of the test wallet (e.g. https://rafiki.money) | - | - | +| `TEST_WALLET_USERNAME` | -- Login email for the test wallet | - | - | +| `TEST_WALLET_PASSWORD` | -- Login password for the test wallet | Yes | - | +| `TEST_WALLET_ADDRESS_URL` | Your wallet address URL that will be connected to extension | - | - | +| `TEST_WALLET_KEY_ID` | ID of the key that will be connected to extension (UUID v4) | - | - | +| `TEST_WALLET_PRIVATE_KEY` | Private key (hex-encoded Ed25519 private key) | Yes | - | +| `TEST_WALLET_PUBLIC_KEY` | Public key (base64-encoded Ed25519 public key) | - | - | +| `FYNBOS_WALLET_ADDRESS_URL` | Fynbos wallet address (used for Fynbos specific tests only) | - | Yes | +| `FYNBOS_USERNAME` | -- Login email for Fynbos wallet | - | Yes | +| `FYNBOS_PASSWORD` | -- Login password for Fynbos wallet | Yes | Yes | To get the `TEST_WALLET_KEY_ID`, `TEST_WALLET_PRIVATE_KEY` and `TEST_WALLET_PUBLIC_KEY`: diff --git a/esbuild/config.ts b/esbuild/config.ts index 26ff7f7e..93ae7d76 100644 --- a/esbuild/config.ts +++ b/esbuild/config.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { readdirSync } from 'node:fs'; import type { BuildOptions } from 'esbuild'; import type { Manifest } from 'webextension-polyfill'; @@ -10,6 +11,13 @@ export const SRC_DIR = path.resolve(ROOT_DIR, 'src'); export const DEV_DIR = path.resolve(ROOT_DIR, 'dev'); export const DIST_DIR = path.resolve(ROOT_DIR, 'dist'); +const KEY_AUTO_ADD_TARGETS = readdirSync( + path.join(SRC_DIR, 'content', 'keyAutoAdd'), + { withFileTypes: true }, +) + .filter((e) => e.isFile()) + .map(({ name }) => path.basename(name, path.extname(name))); + export type Target = (typeof TARGETS)[number]; export type Channel = (typeof CHANNELS)[number]; export type BuildArgs = { @@ -28,10 +36,10 @@ export const options: BuildOptions = { in: path.join(SRC_DIR, 'content', 'index.ts'), out: path.join('content', 'content'), }, - { - in: path.join(SRC_DIR, 'content', 'keyAutoAdd', 'testWallet.ts'), - out: path.join('content', 'keyAutoAdd', 'testWallet'), - }, + ...KEY_AUTO_ADD_TARGETS.map((name) => ({ + in: path.join(SRC_DIR, 'content', 'keyAutoAdd', `${name}.ts`), + out: path.join('content', 'keyAutoAdd', name), + })), { in: path.join(SRC_DIR, 'content', 'polyfill.ts'), out: path.join('polyfill', 'polyfill'), diff --git a/src/background/services/keyAutoAdd.ts b/src/background/services/keyAutoAdd.ts index cfc489ce..ff3c6d66 100644 --- a/src/background/services/keyAutoAdd.ts +++ b/src/background/services/keyAutoAdd.ts @@ -187,8 +187,10 @@ export function walletAddressToProvider(walletAddress: WalletAddress): { switch (host) { case 'ilp.rafiki.money': return { url: 'https://rafiki.money/settings/developer-keys' }; - // case 'eu1.fynbos.me': // fynbos dev - // case 'fynbos.me': // fynbos production + case 'eu1.fynbos.me': + return { url: 'https://eu1.fynbos.dev/settings/keys' }; + case 'fynbos.me': + return { url: 'https://wallet.fynbos.app/settings/keys' }; default: throw new ErrorWithKey('connectWalletKeyService_error_notImplemented'); } diff --git a/src/content/keyAutoAdd/fynbos.ts b/src/content/keyAutoAdd/fynbos.ts new file mode 100644 index 00000000..930b23f5 --- /dev/null +++ b/src/content/keyAutoAdd/fynbos.ts @@ -0,0 +1,124 @@ +// 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'; +// #region: Steps + +type IndexRouteResponse = { + isUser: boolean; + walletInfo: { + walletID: string; + url: string; + }; +}; + +const waitForLogin: Run = async ( + { keyAddUrl }, + { skip, setNotificationSize }, +) => { + let alreadyLoggedIn = window.location.href.startsWith(keyAddUrl); + if (!alreadyLoggedIn) setNotificationSize('notification'); + try { + await sleep(2000); + 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 url = `/?_data=${encodeURIComponent('routes/_index')}`; + const res = await fetch(url, { + headers: { accept: 'application/json' }, + credentials: 'include', + }).catch((error) => { + return Response.json(null, { status: 599, statusText: error.message }); + }); + if (!res.ok) { + throw new Error(`Failed to get wallet details (${res.statusText})`); + } + const data: IndexRouteResponse = await res.json(); + if (data?.walletInfo?.url !== walletAddressUrl) { + throw new ErrorWithKey('connectWalletKeyService_error_accountNotFound'); + } +}; + +const addKey: Run = async ({ nickName, publicKey }) => { + const url = `/settings/keys/add-public?_data=${encodeURIComponent('routes/settings_.keys_.add-public')}`; + const csrfToken = await getCSRFToken(url); + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + credentials: 'include', + body: toFormUrlEncoded({ + csrfToken: csrfToken, + applicationName: nickName, + publicKey: publicKey, + }), + }).catch((error) => { + return Response.json(null, { status: 599, statusText: error.message }); + }); + + if (!res.ok) { + throw new Error(`Failed to upload public key (${res.statusText})`); + } +}; +// #endregion + +// #region: Helpers +const getCSRFToken = async (url: string): Promise => { + const res = await fetch(url, { + headers: { accept: 'application/json' }, + credentials: 'include', + }).catch((error) => { + return Response.json(null, { status: 599, statusText: error.message }); + }); + if (!res.ok) { + throw new Error(`Failed to retrieve CSRF token (${res.statusText})`); + } + + const { csrfToken }: { csrfToken: string } = await res.json(); + + return csrfToken; +}; + +const toFormUrlEncoded = (data: Record) => { + return Object.entries(data) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); +}; +// #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/src/manifest.json b/src/manifest.json index a4d06797..cfe1cf9d 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -22,6 +22,11 @@ "matches": ["https://rafiki.money/*/*"], "js": ["content/keyAutoAdd/testWallet.js"], "run_at": "document_end" + }, + { + "matches": ["https://eu1.fynbos.dev/*", "https://wallet.fynbos.app/*"], + "js": ["content/keyAutoAdd/fynbos.js"], + "run_at": "document_end" } ], "background": { diff --git a/tests/e2e/.env.example b/tests/e2e/.env.example index 74321586..497e8d65 100644 --- a/tests/e2e/.env.example +++ b/tests/e2e/.env.example @@ -13,3 +13,10 @@ TEST_WALLET_ADDRESS_URL="https://ilp.rafiki.money/something" TEST_WALLET_KEY_ID=uuid-v4-key-id TEST_WALLET_PUBLIC_KEY="base-64-public-key==" TEST_WALLET_PRIVATE_KEY="hex-encoded-private-key" + +## If following are not provided, tests that use these will be skipped. + +# Fynbos specific tests, using https://eu1.fynbos.dev +FYNBOS_WALLET_ADDRESS_URL= +FYNBOS_USERNAME= +FYNBOS_PASSWORD= diff --git a/tests/e2e/connectAutoKeyFynbos.spec.ts b/tests/e2e/connectAutoKeyFynbos.spec.ts new file mode 100644 index 00000000..839abcc4 --- /dev/null +++ b/tests/e2e/connectAutoKeyFynbos.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from './fixtures/base'; +import { getJWKS, withResolvers } from '@/shared/helpers'; +import { disconnectWallet, fillPopup } from './pages/popup'; +import { waitForWelcomePage } from './helpers/common'; +import { + acceptGrant, + getContinueWaitTime, + KEYS_PAGE_URL, + LOGIN_PAGE_URL, + revokeKey, + waitForGrantConsentPage, +} from './helpers/fynbos'; + +test('Connect to Fynbos with automatic key addition when not logged-in to wallet', async ({ + page, + popup, + persistentContext: context, + background, + i18n, +}) => { + const username = process.env.FYNBOS_USERNAME!; + const password = process.env.FYNBOS_PASSWORD!; + const walletAddressUrl = process.env.FYNBOS_WALLET_ADDRESS_URL!; + + test.skip(!username || !password || !walletAddressUrl, 'Missing credentials'); + + const { keyId: kid } = await background.evaluate(() => { + return chrome.storage.local.get<{ keyId: string }>(['keyId']); + }); + + const connectButton = await test.step('fill popup', async () => { + const connectButton = await fillPopup(popup, i18n, { + walletAddressUrl, + amount: '10', + recurring: false, + }); + return connectButton; + }); + + await test.step('ensure not logged in', async () => { + await page.goto(KEYS_PAGE_URL); + expect(page.url()).toBe(LOGIN_PAGE_URL); + await page.close(); + }); + + await test.step('ensure key not added already', 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"]`, + ); + + expect(popup.getByTestId('connect-wallet-auto-key-consent')).toBeVisible(); + 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', { + predicate: (page) => page.url().startsWith(LOGIN_PAGE_URL), + timeout: 3 * 1000, + }); + await openedPage.getByLabel('Email').fill(username); + await openedPage.getByLabel('Password').fill(password); + await openedPage.getByRole('button', { name: 'Log in' }).click(); + await openedPage.waitForURL(KEYS_PAGE_URL); + await expect(openedPage.locator('h1').first()).toHaveText('Keys'); + + return openedPage; + }); + + const continueWaitMsPromise = getContinueWaitTime(context, { + walletAddressUrl, + }); + + const keyNickName = await test.step('adds key to wallet', async () => { + const { resolve, promise } = withResolvers(); + page.on('request', async function interceptApplicationName(req) { + if (req.serviceWorker()) return; + if (req.method() !== 'POST') return; + + const url = new URL(req.url()); + if ( + url.pathname.startsWith('/settings/keys/add-public') && + url.searchParams.get('_data') === 'routes/settings_.keys_.add-public' + ) { + const applicationName = req.postDataJSON()?.applicationName; + resolve(applicationName); + page.off('request', interceptApplicationName); + } + }); + const keyNickName = await promise; + expect(keyNickName).not.toBeFalsy(); + + await waitForGrantConsentPage(page); + + 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 keyNickName; + }); + + await test.step('shows wallet consent page', async () => { + await waitForGrantConsentPage(page); + expect(page.getByRole('button', { name: 'Approve' })).toBeVisible(); + }); + + await test.step('connects', async () => { + const continueWaitMs = await continueWaitMsPromise; + await acceptGrant(page, continueWaitMs); + await waitForWelcomePage(page); + + expect( + await background.evaluate(() => chrome.storage.local.get(['connected'])), + ).toEqual({ connected: true }); + }); + + await test.step('cleanup: revoke keys', async () => { + const keyIds = await test.step('get keys to revoke', async () => { + await page.goto(KEYS_PAGE_URL); + const data = await page.evaluate(async () => { + const res = await fetch( + `/settings/keys?_data=${encodeURIComponent('routes/settings.keys')}`, + { credentials: 'include' }, + ); + const data = await res.json(); + return data as { + keys: { + id: string; + applicationName: string; + publicKeyFingerprint: string; + }[]; + }; + }); + + return data.keys + .filter((e) => e.applicationName === keyNickName) + .map((e) => e.id); + }); + + test.slow(keyIds.length > 2, 'Revoking lots of older keys too'); + + await test.step(`revoke keys (${keyIds.length})`, async () => { + for (const keyId of keyIds) { + await test.step(`revoke key ${keyId}`, async () => { + await revokeKey(page, keyId); + }); + } + + const { keys } = await getJWKS(walletAddressUrl); + expect(keys.find((key) => key.kid === kid)).toBeUndefined(); + }); + }); + + await test.step('cleanup: disconnect wallet', async () => { + await disconnectWallet(popup); + }); +}); diff --git a/tests/e2e/env.d.ts b/tests/e2e/env.d.ts index d455bb53..91a383f4 100644 --- a/tests/e2e/env.d.ts +++ b/tests/e2e/env.d.ts @@ -13,6 +13,13 @@ declare global { TEST_WALLET_PUBLIC_KEY: string; /** Hex-encoded private key */ TEST_WALLET_PRIVATE_KEY: string; + + /** Fynbos wallet address (used for Fynbos specific tests only) */ + FYNBOS_WALLET_ADDRESS_URL: string | undefined; + /** Login email for Fynbos wallet */ + FYNBOS_USERNAME: string | undefined; + /** Login password for Fynbos wallet*/ + FYNBOS_PASSWORD: string | undefined; } } } diff --git a/tests/e2e/helpers/fynbos.ts b/tests/e2e/helpers/fynbos.ts new file mode 100644 index 00000000..f55a51f6 --- /dev/null +++ b/tests/e2e/helpers/fynbos.ts @@ -0,0 +1,66 @@ +import type { BrowserContext, Page } from '@playwright/test'; +import type { ConnectDetails } from '../pages/popup'; +import { waitForWelcomePage } from './common'; +import { getWalletInformation } from '@/shared/helpers'; + +export const KEYS_PAGE_URL = `https://eu1.fynbos.dev/settings/keys`; +export const LOGIN_PAGE_URL = `https://eu1.fynbos.dev/login?returnTo=%2Fsettings%2Fkeys`; + +export async function completeGrant(page: Page, continueWaitMs: number) { + await waitForGrantConsentPage(page); + await acceptGrant(page, continueWaitMs); + await waitForWelcomePage(page); +} + +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 getContinueWaitTime( + context: BrowserContext, + params: Pick, +) { + const continueWaitMs = await (async () => { + const defaultWaitMs = 5001; + if (process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1') { + return Promise.resolve(defaultWaitMs); + } + const walletInfo = await getWalletInformation(params.walletAddressUrl); + return await new Promise((resolve) => { + const authServer = new URL(walletInfo.authServer).href; + context.on('requestfinished', async function intercept(req) { + if (!req.serviceWorker()) return; + if (new URL(req.url()).href !== authServer) return; + + const res = await req.response(); + if (!res) return; + const json = await res.json(); + context.off('requestfinished', intercept); + if (typeof json?.continue?.wait !== 'number') { + return resolve(defaultWaitMs); + } + return resolve(json.continue.wait * 1000); + }); + }); + })(); + return continueWaitMs; +} + +export async function acceptGrant(page: Page, continueWaitMs: number) { + await page.waitForTimeout(continueWaitMs); + await page.getByRole('button', { name: 'Approve', exact: true }).click(); +} + +export async function revokeKey(page: Page, keyId: string) { + const baseUrl = KEYS_PAGE_URL; + await page.goto(`${baseUrl}/${keyId}`); + await page.getByRole('button', { name: 'Delete' }).click(); + await page.waitForURL(baseUrl, { timeout: 3000 }); +}