diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml new file mode 100644 index 00000000..2d1fb189 --- /dev/null +++ b/.github/workflows/live-e2e.yml @@ -0,0 +1,151 @@ +name: Live end-to-end + +# Proves real platform identities from the dev app's UI (ts/apps/dev/README.md). +# Not a required check: it depends on the platforms, their accounts and the +# network. Third-party actions are pinned by commit SHA, the tag in a comment. + +on: + pull_request: + # What a live ceremony runs: the dev app, its packages and their build. + paths: + - "ts/apps/dev/**" + - "ts/packages/ceremony/**" + - "ts/packages/popup/**" + - "ts/packages/ledger/**" + - "ts/scripts/build-package.ts" + - "ts/package.json" + - "ts/pnpm-lock.yaml" + - "ts/pnpm-workspace.yaml" + - "ts/.node-version" + - "ts/tsconfig.base.json" + - "brand/**" + - ".github/workflows/live-e2e.yml" + - ".github/actions/setup-ts/**" + workflow_dispatch: + schedule: + - cron: "23 3 * * *" + +permissions: + contents: read + +env: + # Where the stack's output goes; a failed job prints its end. + LIBID_STACK_LOGS: /tmp/libid-live-stack.log + +jobs: + ceremony: + name: GitHub and X ceremonies (live) + # A pull request from a fork or from Dependabot gets no secrets. + if: >- + github.event_name != 'pull_request' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]') + runs-on: ubuntu-latest + # One run at a time per account: two could submit one TOTP code twice. + concurrency: + group: live-e2e-github-x + cancel-in-progress: false + timeout-minutes: 60 # test step 45: stack 10 + 2 tests × 15 + stop 2 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-ts + + - name: Install Chromium + run: pnpm -C ts --filter @libid/dev exec playwright install --with-deps chromium + + - name: Prove GitHub and X identities end to end + id: test + timeout-minutes: 45 + env: + LIBID_LIVE_PLATFORMS: github,x + GH_TEST_ALICE_USERNAME: ${{ secrets.GH_TEST_ALICE_USERNAME }} + GH_TEST_ALICE_PASSWORD: ${{ secrets.GH_TEST_ALICE_PASSWORD }} + GH_TEST_ALICE_TOTP_SECRET: ${{ secrets.GH_TEST_ALICE_TOTP_SECRET }} + X_TEST_ALICE_USERNAME: ${{ secrets.X_TEST_ALICE_USERNAME }} + X_TEST_ALICE_COOKIES: ${{ secrets.X_TEST_ALICE_COOKIES }} + run: pnpm -C ts/apps/dev test:live + + # The stack gets no secret (playwright.live.config.ts); GitHub masks them anyway. + - name: Show the stack log + if: ${{ !cancelled() && steps.test.outcome != 'success' }} + run: tail -n 300 "$LIBID_STACK_LOGS" || true + + - name: Keep the screenshots + if: ${{ !cancelled() && steps.test.outcome != 'success' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: live-e2e-github-x + path: ts/apps/dev/test-results/**/*.png + retention-days: 7 + if-no-files-found: ignore + + google-ssm: + name: Google ceremony through an SSM-reached proxy (live) + # Google challenges a session from a runner's address, so the browser goes + # through an EC2 proxy LIBID_GOOGLE_PROXY_ROLE reaches only by SSM. + # Runs while LIBID_GOOGLE_RUNNER is `ssm`, on main only: the role trusts + # this workflow on main and nothing else. + if: >- + vars.LIBID_GOOGLE_RUNNER == 'ssm' && github.event_name != 'pull_request' + runs-on: ubuntu-latest + concurrency: + group: live-e2e-google + cancel-in-progress: false + timeout-minutes: 45 # test step 30: stack 10 + 1 test × 15 + stop 2 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-ts + + - name: Install Chromium + run: pnpm -C ts --filter @libid/dev exec playwright install --with-deps chromium + + - uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 # v5.0.0 + with: + role-to-assume: ${{ vars.LIBID_GOOGLE_PROXY_ROLE }} + aws-region: eu-central-1 + + # The port-forward runs for the rest of the job; the step fails unless the proxy answers. + - name: Reach the Google proxy over SSM + env: + INSTANCE: ${{ vars.LIBID_GOOGLE_PROXY_INSTANCE }} + run: | + aws ssm start-session --target "$INSTANCE" \ + --document-name AWS-StartPortForwardingSession \ + --parameters portNumber=3128,localPortNumber=3128 >"$RUNNER_TEMP/port-forward.log" 2>&1 & + for _ in $(seq 1 30); do + ip=$(curl -fs --max-time 5 -x http://127.0.0.1:3128 https://api.ipify.org) && break + sleep 2 + done + echo "Google sees ${ip:?the proxy did not answer; see $RUNNER_TEMP/port-forward.log}" + + - name: Prove a Google identity end to end + id: test + timeout-minutes: 30 + env: + LIBID_LIVE_PLATFORMS: google + LIBID_TEST_PROXY: http://127.0.0.1:3128 + GOOGLE_TEST_ALICE_EMAIL: ${{ secrets.GOOGLE_TEST_ALICE_EMAIL }} + GOOGLE_TEST_ALICE_COOKIES: ${{ secrets.GOOGLE_TEST_ALICE_COOKIES }} + run: pnpm -C ts/apps/dev test:live + + - name: Show the stack log + if: ${{ !cancelled() && steps.test.outcome != 'success' }} + run: tail -n 300 "$LIBID_STACK_LOGS" || true + + - name: Keep the screenshots + if: ${{ !cancelled() && steps.test.outcome != 'success' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: live-e2e-google + path: ts/apps/dev/test-results/**/*.png + retention-days: 7 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 62d59993..44918470 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,11 @@ ts/packages/*/node_modules/ ts/apps/*/node_modules/ ts/packages/*/dist/ ts/apps/*/dist/ -# Local development environment files +# Local development environment files and test secrets .env .env.local +.env.*.local +*.env.test # @libid/popup e2e bundles and Playwright output ts/packages/popup/e2e/dist/ diff --git a/ts/.oxlintrc.json b/ts/.oxlintrc.json index 3ca4ad80..1eaed693 100644 --- a/ts/.oxlintrc.json +++ b/ts/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": ["apps/dev/e2e/stealth.js"], "plugins": ["typescript"], "categories": { "correctness": "off" diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md index e901da82..21d37caa 100644 --- a/ts/apps/dev/README.md +++ b/ts/apps/dev/README.md @@ -37,8 +37,8 @@ Edit configuration directly where it is used: - [compose.yaml](compose.yaml): services. - [bridge-config.toml](bridge-config.toml): Bridge configuration and development OAuth credentials. -- [src/app.ts](src/app.ts): Bridge URL and the local ledger/notary fixture; Bridge configuration selects CCDP. -- [vite.config.ts](vite.config.ts): frontend port. +- [local.ts](local.ts): the app origin (frontend port), and the Bridge and notary URLs. +- [src/app.ts](src/app.ts): the local ledger fixture; Bridge configuration selects CCDP. The setup includes shared development OAuth credentials and a public development notary signing key; no environment file is needed. Bridge publishes the GitHub @@ -105,3 +105,56 @@ When isolation fallback occurs, **Prover fallback** shows navigation through Pro readiness, using `prover-fallback` and `prover.started` occurrence timestamps. It excludes work before navigation and does not measure the difference from a run without fallback. Direct runs have no fallback row. + +## Live tests + +The live suite proves real GitHub, X and Google accounts from this app's UI. +It starts its own `pnpm dev` and stops it after, so the ports above must be +free. + +```sh +LIBID_TEST_ENV_FILE=path/to/.env.test pnpm --filter @libid/dev test:live +``` + +The variables carry the names libid-server-rs' ceremony-tests uses, so one +`.env.test` serves both suites; keep it outside the repository, or name it +`.env.test`, which git ignores. Unless `LIBID_LIVE_PLATFORMS` lists it, a +platform whose variables are missing is skipped. + +| Variable | Platform | What | +|---|---|---| +| `GH_TEST_ALICE_USERNAME`, `GH_TEST_ALICE_PASSWORD` | GitHub | The test account. | +| `GH_TEST_ALICE_TOTP_SECRET` | GitHub | The base32 key of its authenticator app. | +| `X_TEST_ALICE_USERNAME` | X | The test account's handle. | +| `X_TEST_ALICE_COOKIES` or `X_TEST_ALICE_COOKIES_FILE` | X | A saved session: base64 of the JSON cookie list from `ceremony export x`, or a path to that JSON, relative to the env file. | +| `GOOGLE_TEST_ALICE_EMAIL` | Google | The test account. | +| `GOOGLE_TEST_ALICE_COOKIES` or `GOOGLE_TEST_ALICE_COOKIES_FILE` | Google | A saved session, made by hand: see [Google's session](#googles-session). | +| `LIBID_TEST_ENV_FILE` | — | A dotenv file with the variables above; exported values win. | +| `LIBID_LIVE_PLATFORMS` | — | Comma-separated platforms to prove, such as `github,x`; each one listed must have its variables. Unset, every platform with its variables runs. | +| `LIBID_TEST_PROXY` | — | A proxy for the browser's traffic, loopback aside. | +| `LIBID_STACK_LOGS` | — | A file all of the stack's output, the build's included, is appended to. | +| `HEADED` | — | Set: show the browser. | + +In CI, [live-e2e.yml](../../../.github/workflows/live-e2e.yml) runs GitHub and X +daily in one job, and Google in another through a proxy whose address Google +accepts. Each job gets only its own platforms' secrets. On a failure it prints +the end of the stack log to the job log, where GitHub masks the secrets, and +uploads the screenshots. + +### Google's session + +Google challenges a saved session used from an address other than the one it +was signed in from, and refuses sign-in in an automated browser. So the session +is made once by hand, through the same proxy CI uses: + +1. Open the proxy locally: + `aws ssm start-session --target --document-name AWS-StartPortForwardingSession --parameters portNumber=3128,localPortNumber=3128`. +2. In plain Chrome with a fresh profile, sign in to the test account through it: + `google-chrome --user-data-dir=/tmp/google-session --password-store=basic --proxy-server=http://127.0.0.1:3128 https://myaccount.google.com/`. + Then close Chrome. +3. Export and store it: `node e2e/export-session.ts /tmp/google-session google.json`, + then `base64 -w0 google.json | gh secret set GOOGLE_TEST_ALICE_COOKIES`, and + delete `google.json` and the profile. + +Do this again when the Google job reports an expired session, or when the +proxy's address changes. diff --git a/ts/apps/dev/e2e/ceremony.live.spec.ts b/ts/apps/dev/e2e/ceremony.live.spec.ts new file mode 100644 index 00000000..aa4effbb --- /dev/null +++ b/ts/apps/dev/e2e/ceremony.live.spec.ts @@ -0,0 +1,92 @@ +import type { PlatformId } from '@libid/ceremony' +import { type BrowserContext, expect, type Page, test } from '@playwright/test' +import { expectReady, launch, newestRun } from '../src/app.fixtures.ts' +import { authorizeOnGitHub } from './github.ts' +import { authorizeOnGoogle } from './google.ts' +import { presentAsPerson } from './person.ts' +import { watchAppFailure, watchReturn } from './popup.ts' +import { googleSession, restoreSession, xSession } from './session.ts' +import { env, missing, runnablePlatforms } from './settings.ts' +import { authorizeOnX } from './x.ts' + +/** + * Start `name`'s ceremony from the app, let `authorize` drive the popup, then + * check the run's result: accepted, for `user` on `platform`. The ceremony + * itself validates the proof, the nodes and the ids. + */ +async function proveThroughTheUi( + page: Page, + context: BrowserContext, + name: string, + platform: PlatformId, + user: string, + authorize: (popup: Page) => Promise, +) { + await page.goto('/') + await expectReady(page, 60_000) + // Watched as it appears: a platform may return it at once. + context.on('page', watchReturn) + const popup = await launch(page, name) + const row = newestRun(page) + const id = (await row.getAttribute('data-ceremony-id'))! + const result = () => + page.evaluate((id) => { + const result = window.results.get(id) + const identity = result && 'identity' in result ? result.identity : undefined + return ( + result && { + status: result.status, + platform: identity?.platformId, + user: identity?.userName, + } + ) + }, id) + const message = async () => (await row.locator('.run-status').textContent()) ?? '' + watchAppFailure(popup, async () => { + const status = (await result())?.status + return status && status !== 'accepted' ? await message() : undefined + }) + await authorize(popup) + + await expect.poll(result, { timeout: 8 * 60_000 }).toBeDefined() + const outcome = (await result())! + if (outcome.status !== 'accepted') expect(outcome.status, await message()).toBe('accepted') + expect(outcome.platform).toBe(platform) + expect(outcome.user?.toLowerCase()).toBe(user.toLowerCase()) +} + +/** Skip `platform`'s test unless it runs: listed, or with its variables set. */ +const skipUnlessLive = (platform: PlatformId) => + test.skip( + !runnablePlatforms().includes(platform), + `not listed in LIBID_LIVE_PLATFORMS, or ${missing(platform).join(', ')} not set`, + ) + +test('a GitHub identity is proved in the browser', async ({ page, context }) => { + skipUnlessLive('github') + const account = { + username: env('GH_TEST_ALICE_USERNAME')!, + password: env('GH_TEST_ALICE_PASSWORD')!, + totpSecret: env('GH_TEST_ALICE_TOTP_SECRET')!, + } + await proveThroughTheUi(page, context, 'GitHub', 'github', account.username, (popup) => + authorizeOnGitHub(popup, account), + ) +}) + +test('an X identity is proved in the browser', async ({ page, context }) => { + skipUnlessLive('x') + await presentAsPerson(context) + await restoreSession(context, xSession()!) + await proveThroughTheUi(page, context, 'X', 'x', env('X_TEST_ALICE_USERNAME')!, authorizeOnX) +}) + +test('a Google identity is proved in the browser', async ({ page, context }) => { + skipUnlessLive('google') + const email = env('GOOGLE_TEST_ALICE_EMAIL')! + await presentAsPerson(context) + await restoreSession(context, googleSession()!) + await proveThroughTheUi(page, context, 'Google', 'google', email, (popup) => + authorizeOnGoogle(popup, email), + ) +}) diff --git a/ts/apps/dev/e2e/export-session.ts b/ts/apps/dev/e2e/export-session.ts new file mode 100644 index 00000000..8be5ac31 --- /dev/null +++ b/ts/apps/dev/e2e/export-session.ts @@ -0,0 +1,24 @@ +// Saves a Chrome profile's session as the JSON GOOGLE_TEST_ALICE_COOKIES holds +// (base64-encoded). Google refuses sign-in in an automated browser, so a person +// signs in first, in plain Chrome, through the proxy CI uses: +// +// google-chrome --user-data-dir=/tmp/google-session --password-store=basic \ +// --proxy-server=http://127.0.0.1:3128 https://myaccount.google.com/ +// +// Close that Chrome, then: node e2e/export-session.ts /tmp/google-session out.json +import { chromium } from '@playwright/test' + +const [profile, out] = process.argv.slice(2) +if (!profile || !out) throw new Error('Usage: node e2e/export-session.ts ') + +// The same Chrome and password store that wrote the profile, so its cookies decrypt. +const context = await chromium.launchPersistentContext(profile, { + channel: 'chrome', + headless: true, + args: ['--password-store=basic'], +}) +await context.storageState({ path: out }) +await context.close() +console.log( + `Saved ${out}. It is a credential: keep it out of the repository and delete it after use.`, +) diff --git a/ts/apps/dev/e2e/github.test.ts b/ts/apps/dev/e2e/github.test.ts new file mode 100644 index 00000000..07a1c978 --- /dev/null +++ b/ts/apps/dev/e2e/github.test.ts @@ -0,0 +1,9 @@ +// The TOTP code against the reference vector libid-server-rs' github.rs checks. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { totp } from './github.ts' + +test('the TOTP code matches the reference vector, spaces and case aside', () => { + assert.equal(totp('JBSWY3DPEHPK3PXP', 1_700_000_000_000), '324550') + assert.equal(totp('jbsw y3dp ehpk 3pxp', 1_700_000_000_000), '324550') +}) diff --git a/ts/apps/dev/e2e/github.ts b/ts/apps/dev/e2e/github.ts new file mode 100644 index 00000000..a54bccae --- /dev/null +++ b/ts/apps/dev/e2e/github.ts @@ -0,0 +1,128 @@ +import type { Page } from '@playwright/test' +import * as OTPAuth from 'otpauth' +import { drivePopup, type Pause, throttledClick } from './popup.ts' + +interface GitHubAccount { + username: string + password: string + /** The base32 key of the account's authenticator app. */ + totpSecret: string +} + +/** The TOTP code for the base32 `secret`, spaces and case aside, at `time` (ms). */ +export function totp(secret: string, time = Date.now()): string { + return new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(secret.replace(/\s/g, '').toUpperCase()), + }).generate({ timestamp: time }) +} + +/** What the login has done so far: the request a restart reopens, the login filled, the + * step whose code went, the button clicks, and back-off counts. */ +interface Progress { + start?: string + filled: boolean + totpStep?: number + click: ReturnType + rateLimited: number + errored: number +} + +/** Count one more `what` under `counter`; wait `step` times the count, failing on the fourth. */ +async function backOff( + progress: Progress, + counter: 'rateLimited' | 'errored', + step: number, + what: string, + pause: Pause, +) { + const count = ++progress[counter] + if (count > 3) throw new Error(`${what} three times`) + await pause(step * count, `${what} ${count} times`) +} + +async function onLogin(popup: Page, account: GitHubAccount, progress: Progress) { + if (progress.filled || !(await popup.locator('#login_field').count())) return + progress.filled = true + await popup.fill('#login_field', account.username) + await popup.fill('#password', account.password) + await popup.click('input[type=submit][name=commit]') +} + +/** One code per 30-second step, submitted once: GitHub submits a full code itself. */ +async function onTotp(popup: Page, account: GitHubAccount, progress: Progress) { + const now = Date.now() + const step = Math.floor(now / 30_000) + if (progress.totpStep === step || !(await popup.locator('#app_totp').count())) return + progress.totpStep = step + await popup.fill('#app_totp', totp(account.totpSecret, now)) + const left = await popup + .waitForURL((url) => url.pathname !== '/sessions/two-factor/app', { timeout: 3_000 }) + .then(() => true) + .catch(() => false) + if (!left) + await popup + .locator('button[type=submit]') + .click({ timeout: 2_000 }) + .catch(() => {}) +} + +/** Act on one GitHub page. */ +async function onGitHubPage( + popup: Page, + account: GitHubAccount, + progress: Progress, + url: URL, + text: string, + pause: Pause, +) { + progress.start ??= url.href + const path = url.pathname + if ( + text.includes("couldn't respond to your request in time") || + text.includes('something went wrong') + ) { + await backOff(progress, 'errored', 10_000, 'GitHub answered its error page', pause) + // Reopening the request keeps the popup on github.com until GitHub redirects. + await popup.goto(progress.start).catch(() => {}) + progress.filled = false + progress.totpStep = undefined + } else if (path === '/login' || path === '/session') { + if (text.includes('too many requests') || text.includes('rate limit')) { + await backOff(progress, 'rateLimited', 30_000, 'GitHub rate-limited the login', pause) + await popup.reload().catch(() => {}) + progress.filled = false + } else await onLogin(popup, account, progress) + } else if (path === '/sessions/two-factor/app') await onTotp(popup, account, progress) + else if (path.startsWith('/sessions/two-factor')) + await popup.goto('https://github.com/sessions/two-factor/app').catch(() => {}) + else if (path.startsWith('/sessions/verified-device')) + throw new Error('GitHub asked for device verification: the account has no TOTP method') + // GitHub's periodic 2FA checkup, served in place of the page asked for. + else if (text.includes('verify your two-factor authentication')) + await progress.click(popup.getByText('skip 2FA verification')) + else if (path === '/login/oauth/authorize') { + const authorized = await progress.click(popup.locator("button[name='authorize'][value='1']")) + if (!authorized && (text.includes('redirect_uri') || text.includes('be careful'))) + throw new Error( + "GitHub refused the authorization request: the app's callback URL does not match", + ) + } +} + +/** + * Sign in and authorize on GitHub in `popup`, until GitHub redirects it to + * Bridge's callback. A port of libid-server-rs' ceremony-tests/src/browser/github.rs. + */ +export async function authorizeOnGitHub(popup: Page, account: GitHubAccount) { + const progress: Progress = { + filled: false, + click: throttledClick(), + rateLimited: 0, + errored: 0, + } + await drivePopup(popup, { + name: 'GitHub', + onHost: (url) => url.hostname === 'github.com', + step: (url, text, pause) => onGitHubPage(popup, account, progress, url, text, pause), + }) +} diff --git a/ts/apps/dev/e2e/google.ts b/ts/apps/dev/e2e/google.ts new file mode 100644 index 00000000..d03cd560 --- /dev/null +++ b/ts/apps/dev/e2e/google.ts @@ -0,0 +1,46 @@ +import type { Page } from '@playwright/test' +import { drivePopup, throttledClick } from './popup.ts' + +/** Pages that no automation gets past: the session or the OAuth client needs a person. */ +const REFUSED: [string, string][] = [ + ['this browser or app may not be secure', 'Google refused this browser'], + ["verify it's you", 'Google asks to verify the account interactively'], + ["verify you're human", 'Google asks to verify the account interactively'], + ['redirect_uri_mismatch', 'Google rejected the redirect URI of the OAuth client'], + ['access blocked', 'Google blocked the OAuth client'], + ['has not completed the google verification process', 'Google blocked the OAuth client'], + ['invalid_client', 'Google does not know the OAuth client'], + ['unauthorized_client', 'Google refused the OAuth client'], + ['unsupported_response_type', 'Google refused the id_token response type'], +] + +/** + * Choose the account and consent on Google in `popup`, signed in by the saved + * session, until Google returns it to Bridge's callback. A port of libid-server-rs' + * ceremony-tests/src/browser/google.rs without its sign-in: a login page means + * the saved session has expired. + */ +export async function authorizeOnGoogle(popup: Page, email: string) { + const click = throttledClick() + await drivePopup(popup, { + name: 'Google', + onHost: (url) => url.hostname === 'accounts.google.com', + step: async (url, text) => { + const refused = REFUSED.find(([marker]) => text.includes(marker)) + if (refused) throw new Error(`${refused[1]} (${url.pathname})`) + if (url.pathname.includes('/challenge/')) + throw new Error(`Google asks to verify the account interactively (${url.pathname})`) + const login = popup.locator('input[type=password],input[type=email],input[name=identifier]') + if (await login.filter({ visible: true }).count()) + throw new Error( + "Google asked to sign in: the saved session has expired. Renew it by hand through the proxy (README, Google's session).", + ) + // The account's row, else a Continue or Allow button. + await click( + popup + .locator(`[data-identifier="${email}" i]`) + .or(popup.getByRole('button', { name: /^(continue|allow)$/i })), + ) + }, + }) +} diff --git a/ts/apps/dev/e2e/person.ts b/ts/apps/dev/e2e/person.ts new file mode 100644 index 00000000..f8f829d0 --- /dev/null +++ b/ts/apps/dev/e2e/person.ts @@ -0,0 +1,47 @@ +import { fileURLToPath } from 'node:url' +import type { BrowserContext, Page } from '@playwright/test' + +/** The Chrome these tests present as: a person's, on an Intel Mac. */ +const USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36' + +const brand = (name: string, version: string) => ({ brand: name, version }) + +/** Client hints matching USER_AGENT, which Chrome reports in navigator.userAgentData and sec-ch-ua. */ +const userAgentOverride = { + userAgent: USER_AGENT, + acceptLanguage: 'en-US,en;q=0.9', + platform: 'macOS', + userAgentMetadata: { + brands: [brand('Chromium', '145'), brand('Not:A-Brand', '99'), brand('Google Chrome', '145')], + fullVersionList: [ + brand('Chromium', '145.0.0.0'), + brand('Not:A-Brand', '99.0.0.0'), + brand('Google Chrome', '145.0.0.0'), + ], + platform: 'macOS', + platformVersion: '15.3.0', + architecture: 'x86', + model: '', + mobile: false, + bitness: '64', + wow64: false, + }, +} + +async function presentPage(context: BrowserContext, page: Page) { + const cdp = await context.newCDPSession(page) + await cdp.send('Emulation.setUserAgentOverride', userAgentOverride) +} + +/** + * Make every page of `context` read as a person's Chrome: the user agent and + * its client hints, and the stealth script before any page script. X and + * Google challenge automation they can see. Ported from libid-server-rs' + * ceremony-tests/src/browser/person.rs. + */ +export async function presentAsPerson(context: BrowserContext) { + await context.addInitScript({ path: fileURLToPath(new URL('./stealth.js', import.meta.url)) }) + for (const page of context.pages()) await presentPage(context, page) + context.on('page', (page) => void presentPage(context, page).catch(() => {})) +} diff --git a/ts/apps/dev/e2e/popup.ts b/ts/apps/dev/e2e/popup.ts new file mode 100644 index 00000000..195b2a88 --- /dev/null +++ b/ts/apps/dev/e2e/popup.ts @@ -0,0 +1,99 @@ +import { setTimeout as sleep } from 'node:timers/promises' +import type { Locator, Page } from '@playwright/test' +import { BRIDGE } from '../local.ts' + +const BUDGET_MS = 180_000 + +/** Where every platform returns the popup: the local Bridge's OAuth callback. */ +const CALLBACK = new URL('/auth/callback', BRIDGE) + +/** Wait `ms` inside the driver's budget; reject with `reason` if the wait would pass it. */ +export type Pause = (ms: number, reason: string) => Promise + +interface Driver { + /** The platform's name, for the timeout message. */ + name: string + /** Whether `url` is one of the platform's own pages. */ + onHost: (url: URL) => boolean + /** + * Act on one of the platform's pages; `text` is its visible text, whitespace + * collapsed, lowercased, with typographic apostrophes as plain ones. + */ + step: (url: URL, text: string, pause: Pause) => Promise +} + +const isCallback = (url: string) => { + const parsed = URL.parse(url) + return parsed?.origin === CALLBACK.origin && parsed.pathname === CALLBACK.pathname +} + +const returned = new WeakSet() + +/** Note `popup`'s navigation to Bridge's callback from now on; call it as the popup appears. */ +export const watchReturn = (popup: Page) => + popup.on('request', (request) => { + if (request.isNavigationRequest() && isCallback(request.url())) returned.add(popup) + }) + +const failures = new WeakMap Promise>() + +/** Let `drivePopup` stop with the app's reason once `check` reports its run failed. */ +export const watchAppFailure = (popup: Page, check: () => Promise) => + failures.set(popup, check) + +/** + * Drive `popup` through a platform's pages until the platform sends it to + * Bridge's callback; fail on the budget, naming the page it stopped on. + */ +export async function drivePopup(popup: Page, driver: Driver) { + const deadline = Date.now() + BUDGET_MS + const hasReturned = () => returned.has(popup) || (!popup.isClosed() && isCallback(popup.url())) + const pause: Pause = async (ms, reason) => { + if (Date.now() + ms > deadline) + throw new Error( + `${reason}; waiting ${ms / 1000} s more would pass the ${BUDGET_MS / 1000} s budget`, + ) + await sleep(ms) + } + let checkedAt = 0 + while (Date.now() < deadline) { + if (hasReturned()) return + if (Date.now() - checkedAt > 2_000) { + checkedAt = Date.now() + const failed = await failures.get(popup)?.() + if (failed) + throw new Error(`The app's run failed during the ${driver.name} authorization: ${failed}`) + } + if (popup.isClosed()) + throw new Error(`The popup closed before ${driver.name} returned it to Bridge`) + const url = URL.parse(popup.url()) + if (url?.protocol === 'chrome-error:') + throw new Error(`The popup could not load a page during the ${driver.name} authorization`) + if (url && driver.onHost(url)) { + const visible = await popup + .locator('body') + .innerText({ timeout: 2_000 }) + .catch(() => '') + const text = visible.split(/\s+/).join(' ').trim().toLowerCase().replaceAll('’', "'") + await driver.step(url, text, pause) + } + await sleep(250) + } + if (hasReturned()) return + throw new Error( + `No ${driver.name} authorization in ${BUDGET_MS / 1000} s; stopped on ${popup.url()}`, + ) +} + +/** A click on the first visible match of a locator, at most once every 5 s; whether it found one. */ +export function throttledClick() { + let clickedAt = 0 + return async (target: Locator) => { + const visible = target.filter({ visible: true }).first() + if (Date.now() - clickedAt < 5_000 || !(await visible.count())) return false + clickedAt = Date.now() + // The page may already be leaving; the next poll sees where it went. + await visible.click({ timeout: 5_000 }).catch(() => {}) + return true + } +} diff --git a/ts/apps/dev/e2e/session.test.ts b/ts/apps/dev/e2e/session.test.ts new file mode 100644 index 00000000..ad6afe5d --- /dev/null +++ b/ts/apps/dev/e2e/session.test.ts @@ -0,0 +1,36 @@ +// The mapping restoreSession applies, on a synthetic export. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { cookieParam } from './session.ts' + +test('cookies map to addCookies as the export spells them', () => { + const domain = cookieParam({ + name: 'a', + value: '1', + domain: '.x.com', + sameSite: 'no_restriction', + expires: 9e9, + }) + assert.deepEqual( + [domain.domain, domain.path, domain.sameSite, domain.expires], + ['.x.com', '/', 'None', 9e9], + ) + const hostOnly = cookieParam({ + name: 'b', + value: '2', + domain: 'x.com', + path: '/i', + same_site: '', + partition_key: { topLevelSite: 'https://x.com' }, + }) + assert.deepEqual( + [ + hostOnly.domain, + hostOnly.path, + 'sameSite' in hostOnly, + hostOnly.expires, + hostOnly.partitionKey, + ], + ['x.com', '/i', false, -1, 'https://x.com'], + ) +}) diff --git a/ts/apps/dev/e2e/session.ts b/ts/apps/dev/e2e/session.ts new file mode 100644 index 00000000..6101c8d5 --- /dev/null +++ b/ts/apps/dev/e2e/session.ts @@ -0,0 +1,101 @@ +// Saved platform sessions: decoded, kept to the platform's hosts, restored. +import { readFileSync } from 'node:fs' +import type { BrowserContext } from '@playwright/test' +import { env, settingPath } from './settings.ts' + +/** One cookie as libid-server-rs' `ceremony export` or Playwright saves it; both key casings occur. */ +interface Stored { + name: string + value: string + domain: string + path?: string + secure?: boolean | null + http_only?: boolean + httpOnly?: boolean + same_site?: string | null + sameSite?: string | null + expires?: number + expirationDate?: number + partition_key?: string | { topLevelSite?: string } | null + partitionKey?: string | { topLevelSite?: string } | null +} + +const hostOf = (cookie: Stored) => cookie.domain.replace(/^\./, '') +export const onX = (host: string) => host === 'x.com' || host.endsWith('.x.com') +const onGoogle = (host: string) => host === 'google.com' || host.endsWith('.google.com') + +/** + * The platform's cookies in `_COOKIES` (base64 JSON) or `_COOKIES_FILE`: + * a list, or one under `cookies`, one of them `signedIn` (the Rust suite's signed_in). + */ +function saved( + prefix: string, + keep: (host: string) => boolean, + signedIn: (cookie: Stored) => boolean, + needs: string, +): Stored[] | undefined { + const encoded = env(`${prefix}_COOKIES`) + const file = env(`${prefix}_COOKIES_FILE`) + if (!encoded && !file) return undefined + let cookies: unknown + try { + const parsed = JSON.parse( + encoded + ? Buffer.from(encoded.trim(), 'base64').toString('utf8') + : readFileSync(settingPath(file!), 'utf8'), + ) + cookies = Array.isArray(parsed) ? parsed : parsed?.cookies + } catch { + // The parser's message would quote the value. + } + if (!Array.isArray(cookies)) + throw new Error(`The saved ${prefix} session is not a JSON cookie list or { cookies }`) + const kept = (cookies as Stored[]).filter((cookie) => keep(hostOf(cookie))) + if (!kept.some(signedIn)) throw new Error(`The saved ${prefix} session has no ${needs}`) + return kept +} + +export const xSession = () => + saved('X_TEST_ALICE', onX, (cookie) => cookie.name === 'auth_token', 'auth_token') + +export const googleSession = () => + saved( + 'GOOGLE_TEST_ALICE', + onGoogle, + (cookie) => + cookie.domain.endsWith('accounts.google.com') && + (cookie.name === 'LSID' || cookie.name === '__Host-1PLSID'), + 'LSID or __Host-1PLSID on accounts.google.com', + ) + +const SAME_SITE: Record = { + strict: 'Strict', + lax: 'Lax', + none: 'None', + no_restriction: 'None', +} + +type CookieParam = Parameters[0][number] + +/** A cookie as addCookies takes it; a dotless domain stays host-only. */ +export function cookieParam(cookie: Stored): CookieParam { + const sameSite = SAME_SITE[(cookie.same_site ?? cookie.sameSite ?? '').toLowerCase()] + const expires = cookie.expires ?? cookie.expirationDate ?? 0 + // The Rust export writes CDP's object; Playwright takes its top-level site. + const key = cookie.partition_key ?? cookie.partitionKey + const partitionKey = typeof key === 'string' ? key : key?.topLevelSite + return { + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path || '/', + secure: cookie.secure ?? true, + httpOnly: cookie.http_only ?? cookie.httpOnly ?? false, + expires: expires > 0 ? expires : -1, + ...(sameSite && { sameSite }), + ...(partitionKey && { partitionKey }), + } +} + +export const restoreSession = (context: BrowserContext, cookies: Stored[]) => + context.addCookies(cookies.map(cookieParam)) diff --git a/ts/apps/dev/e2e/settings.ts b/ts/apps/dev/e2e/settings.ts new file mode 100644 index 00000000..2ea11589 --- /dev/null +++ b/ts/apps/dev/e2e/settings.ts @@ -0,0 +1,69 @@ +// The live tests' settings, under the names libid-server-rs' ceremony-tests +// reads from the same .env.test. An empty value counts as unset. +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { parseEnv } from 'node:util' +import type { PlatformId } from '@libid/ceremony' + +/** Each platform's variables, each set by any one name of its list. */ +const PLATFORM_VARIABLES: Record = { + github: [['GH_TEST_ALICE_USERNAME'], ['GH_TEST_ALICE_PASSWORD'], ['GH_TEST_ALICE_TOTP_SECRET']], + x: [['X_TEST_ALICE_USERNAME'], ['X_TEST_ALICE_COOKIES', 'X_TEST_ALICE_COOKIES_FILE']], + google: [ + ['GOOGLE_TEST_ALICE_EMAIL'], + ['GOOGLE_TEST_ALICE_COOKIES', 'GOOGLE_TEST_ALICE_COOKIES_FILE'], + ], +} +const PLATFORMS = Object.keys(PLATFORM_VARIABLES) as PlatformId[] + +export const env = (name: string) => process.env[name] || undefined + +const invoked = () => process.env.INIT_CWD ?? process.cwd() +/** The file LIBID_TEST_ENV_FILE names, relative to where pnpm was invoked. */ +const envFile = () => env('LIBID_TEST_ENV_FILE') && resolve(invoked(), env('LIBID_TEST_ENV_FILE')!) + +/** Read the env file, if one is named, into the environment; exported values win. */ +export function loadEnvFile() { + const file = envFile() + if (!file) return + for (const [name, value] of Object.entries(parseEnv(readFileSync(file, 'utf8')))) + process.env[name] ??= value +} + +/** A path setting: relative to the env file, else to where pnpm was invoked. */ +export const settingPath = (path: string) => { + const file = envFile() + return resolve(file ? dirname(file) : invoked(), path) +} + +/** The platforms LIBID_LIVE_PLATFORMS lists, comma-separated; undefined when it is unset. */ +function listed(): PlatformId[] | undefined { + const names = env('LIBID_LIVE_PLATFORMS') + ?.split(',') + .map((name) => name.trim()) + const unknown = names?.filter((name) => !PLATFORMS.includes(name as PlatformId)) ?? [] + if (unknown.length) + throw new Error(`LIBID_LIVE_PLATFORMS names ${unknown.join(', ')}; use ${PLATFORMS.join(', ')}`) + return names as PlatformId[] | undefined +} + +/** The variables `platform` lacks. */ +export const missing = (platform: PlatformId) => + PLATFORM_VARIABLES[platform] + .filter((names) => !names.some(env)) + .map((names) => names.join(' or ')) + +/** The platforms whose tests run: the listed ones, else every one with its variables set. */ +export const runnablePlatforms = () => + listed() ?? PLATFORMS.filter((platform) => !missing(platform).length) + +/** A listed platform must have its variables: fail once, naming every one absent. */ +export function checkSettings() { + const absent = (listed() ?? []).flatMap((p) => + missing(p).length ? [`${p}: ${missing(p).join(', ')}`] : [], + ) + if (absent.length) + throw new Error( + `Missing live test settings (${absent.join('; ')}). Put them in the file LIBID_TEST_ENV_FILE names, or export them.`, + ) +} diff --git a/ts/apps/dev/e2e/stealth.js b/ts/apps/dev/e2e/stealth.js new file mode 100644 index 00000000..3850f976 --- /dev/null +++ b/ts/apps/dev/e2e/stealth.js @@ -0,0 +1,267 @@ +// Ported from libid-server-rs ceremony-tests/src/browser/stealth.js, and +// installed in every page by presentAsPerson (person.ts). +// Runs in every new document before the page's own scripts: what the page +// can read is a person's Chrome on a Mac. + +// `navigator.webdriver` is `undefined`, as in a Chrome nobody automates. +Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true }) +Object.defineProperty(Navigator.prototype, 'webdriver', { + get: () => undefined, + configurable: true, +}) +Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }) +Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' }) +Object.defineProperty(navigator, 'platform', { get: () => 'MacIntel' }) + +// The client hints are the user agent metadata presentAsPerson (person.ts) +// sets over CDP, which Chrome itself reports through `navigator.userAgentData` and the +// `sec-ch-ua` headers. + +// `window.chrome`, with the members a page reads off it. +window.chrome = { + app: { + isInstalled: false, + InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, + RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' }, + getDetails: function () { + return null + }, + getIsInstalled: function () { + return false + }, + runningState: function () { + return 'cannot_run' + }, + }, + runtime: { + OnInstalledReason: { + CHROME_UPDATE: 'chrome_update', + INSTALL: 'install', + SHARED_MODULE_UPDATE: 'shared_module_update', + UPDATE: 'update', + }, + OnRestartRequiredReason: { + APP_UPDATE: 'app_update', + OS_UPDATE: 'os_update', + PERIODIC: 'periodic', + }, + PlatformArch: { + ARM: 'arm', + ARM64: 'arm64', + MIPS: 'mips', + MIPS64: 'mips64', + X86_32: 'x86-32', + X86_64: 'x86-64', + }, + PlatformNaclArch: { + ARM: 'arm', + MIPS: 'mips', + MIPS64: 'mips64', + X86_32: 'x86-32', + X86_64: 'x86-64', + }, + PlatformOs: { + ANDROID: 'android', + CROS: 'cros', + FUCHSIA: 'fuchsia', + LINUX: 'linux', + MAC: 'mac', + OPENBSD: 'openbsd', + WIN: 'win', + }, + RequestUpdateCheckStatus: { + NO_UPDATE: 'no_update', + THROTTLED: 'throttled', + UPDATE_AVAILABLE: 'update_available', + }, + connect: function () {}, + sendMessage: function () {}, + }, + csi: function () { + return {} + }, + loadTimes: function () { + return {} + }, +} + +// The PDF viewer plugins a desktop Chrome lists. +Object.defineProperty(navigator, 'plugins', { + get: () => { + const plugin = (name, filename, description) => { + const p = { name, filename, description, length: 1 } + p[0] = { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' } + return p + } + const plugins = [ + plugin('PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'), + plugin('Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'), + plugin('Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'), + plugin('Microsoft Edge PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'), + plugin('WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format'), + ] + plugins.item = (i) => plugins[i] || null + plugins.namedItem = (n) => plugins.find((p) => p.name === n) || null + plugins.refresh = () => {} + return plugins + }, +}) +Object.defineProperty(navigator, 'mimeTypes', { + get: () => { + const types = [ + { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' }, + ] + types.item = (i) => types[i] || null + types.namedItem = (n) => types.find((m) => m.type === n) || null + return types + }, +}) + +// The notifications permission answers as `Notification.permission` does. +const originalQuery = window.navigator.permissions.query.bind(window.navigator.permissions) +window.navigator.permissions.query = (parameters) => + parameters.name === 'notifications' + ? Promise.resolve({ state: Notification.permission }) + : originalQuery(parameters) + +// WebGL names an Intel GPU. +const getParameter = WebGLRenderingContext.prototype.getParameter +WebGLRenderingContext.prototype.getParameter = function (parameter) { + if (parameter === 37445) return 'Intel Inc.' + if (parameter === 37446) return 'Intel Iris OpenGL Engine' + return getParameter.call(this, parameter) +} + +// A 1920x1080 screen with a 1440x900 window on it. +Object.defineProperty(screen, 'width', { get: () => 1920 }) +Object.defineProperty(screen, 'height', { get: () => 1080 }) +Object.defineProperty(screen, 'availWidth', { get: () => 1920 }) +Object.defineProperty(screen, 'availHeight', { get: () => 1080 }) +Object.defineProperty(screen, 'colorDepth', { get: () => 24 }) +Object.defineProperty(screen, 'pixelDepth', { get: () => 24 }) +Object.defineProperty(window, 'outerWidth', { get: () => 1440 }) +Object.defineProperty(window, 'outerHeight', { get: () => 900 }) +Object.defineProperty(window, 'innerWidth', { get: () => 1440 }) +Object.defineProperty(window, 'innerHeight', { get: () => 900 }) + +// A wired connection, eight cores, eight gigabytes. +Object.defineProperty(navigator, 'connection', { + get: () => ({ + effectiveType: '4g', + rtt: 50, + downlink: 10, + saveData: false, + }), +}) +Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }) +Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 }) + +// The time zone of the locale. +const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions +Intl.DateTimeFormat.prototype.resolvedOptions = function () { + const options = resolvedOptions.call(this) + options.timeZone = 'America/New_York' + return options +} + +// Canvas and audio fingerprints differ from run to run by one step. +const toDataURL = HTMLCanvasElement.prototype.toDataURL +HTMLCanvasElement.prototype.toDataURL = function (type) { + try { + const context = this.getContext('2d') + if (context && this.width > 0 && this.height > 0) { + const image = context.getImageData(0, 0, this.width, this.height) + for (let i = 0; i < Math.min(image.data.length, 40); i += 4) { + image.data[i] = (image.data[i] + 1) & 0xff + } + context.putImageData(image, 0, 0) + } + } catch (e) {} + return toDataURL.call(this, type) +} +if (typeof AudioContext !== 'undefined') { + const getChannelData = AudioBuffer.prototype.getChannelData + AudioBuffer.prototype.getChannelData = function (channel) { + const data = getChannelData.call(this, channel) + if (data.length > 0) data[0] += 0.0000001 + return data + } +} + +// No automation globals on `window`. +for (const key of Object.keys(window)) { + if ( + key.startsWith('cdc_') || + key.startsWith('__playwright') || + key.startsWith('__selenium') || + key.startsWith('__webdriver') || + key.startsWith('__driver_') || + key === 'callPhantom' || + key === '_phantom' || + key === 'domAutomation' || + key === 'domAutomationController' + ) { + delete window[key] + } +} + +// Stack traces carry no devtools frames. +const prepareStackTrace = Error.prepareStackTrace +Error.prepareStackTrace = function (error, stack) { + const filtered = stack.filter((frame) => { + const fn = frame.getFunctionName() || '' + const file = frame.getFileName() || '' + return ( + !file.includes('__puppeteer') && + !file.includes('pptr:') && + !fn.includes('Runtime.evaluate') && + !file.includes('devtools') && + !file.includes('__chromium') && + !file.includes('chrome-extension://') && + !file.includes('//# sourceURL=') + ) + }) + if (prepareStackTrace) return prepareStackTrace(error, filtered) + return error + '\n' + filtered.map((f) => ' at ' + f).join('\n') +} + +// Child frames answer `webdriver` the same way. +const contentWindow = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow') +if (contentWindow) { + Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', { + get: function () { + const win = contentWindow.get.call(this) + if (!win) return win + try { + Object.defineProperty(win.navigator, 'webdriver', { + get: () => undefined, + configurable: true, + }) + } catch (e) {} + return win + }, + }) +} + +// The codecs a desktop Chrome plays. +const canPlayType = HTMLMediaElement.prototype.canPlayType +HTMLMediaElement.prototype.canPlayType = function (type) { + const result = canPlayType.call(this, type) + if (result === '') { + if (type === 'video/mp4; codecs="avc1.42E01E"') return 'probably' + if (type === 'video/mp4; codecs="avc1.4D401E"') return 'probably' + if (type === 'video/mp4; codecs="avc1.64001E"') return 'probably' + if (type === 'video/webm; codecs="vp8"') return 'probably' + if (type === 'video/webm; codecs="vp9"') return 'probably' + if (type === 'audio/mpeg') return 'probably' + if (type === 'audio/ogg; codecs="vorbis"') return 'probably' + if (type === 'audio/mp4; codecs="mp4a.40.2"') return 'probably' + } + return result +} + +// `performance.now()` carries sub-millisecond noise. +const now = performance.now.bind(performance) +performance.now = function () { + return now() + Math.random() * 0.1 +} diff --git a/ts/apps/dev/e2e/x.ts b/ts/apps/dev/e2e/x.ts new file mode 100644 index 00000000..d63b69c8 --- /dev/null +++ b/ts/apps/dev/e2e/x.ts @@ -0,0 +1,47 @@ +import type { Page } from '@playwright/test' +import { drivePopup, throttledClick } from './popup.ts' +import { onX } from './session.ts' + +const CHALLENGE = [ + 'performing security verification', + 'verify you are human', + 'verifies you are not a bot', + 'checking your browser', +] + +/** + * Approve the OAuth app on X in `popup`, signed in by the saved session, until + * X returns it to Bridge's callback. A port of libid-server-rs' ceremony-tests/src/browser/x.rs + * without its interactive sign-in: a login page means the saved session has + * expired. + */ +export async function authorizeOnX(popup: Page) { + const click = throttledClick() + let cookiesAccepted = false + let challengedAt: number | undefined + await drivePopup(popup, { + name: 'X', + onHost: (url) => onX(url.hostname), + step: async (url, text) => { + if (CHALLENGE.some((marker) => text.includes(marker))) { + challengedAt ??= Date.now() + if (Date.now() - challengedAt > 90_000) + throw new Error('X security verification did not clear within 90 s') + } else if (url.pathname.startsWith('/i/flow/login') || url.pathname === '/login') { + throw new Error( + 'X asked to sign in: the saved session has expired. Renew it with `ceremony export x` (libid-server-rs).', + ) + } else if (url.pathname.startsWith('/i/oauth2/authorize')) { + challengedAt = undefined + if (!cookiesAccepted) + cookiesAccepted = await click(popup.getByRole('button', { name: 'Accept all cookies' })) + // X serves more than one consent page. + await click( + popup + .getByTestId('OAuth_Consent_Button') + .or(popup.getByRole('button', { name: /^authorize app$/i })), + ) + } + }, + }) +} diff --git a/ts/apps/dev/local.ts b/ts/apps/dev/local.ts new file mode 100644 index 00000000..1f4c186d --- /dev/null +++ b/ts/apps/dev/local.ts @@ -0,0 +1,10 @@ +// The origins the app, services.ts and the tests share. compose.yaml and +// bridge-config.toml repeat the ports they need. + +export const APP_PORT = 4691 +/** The app, served by Vite; bridge-config.toml admits this origin. */ +export const APP_ORIGIN = `http://localhost:${APP_PORT}` +export const BRIDGE = 'http://localhost:4682' +/** Bridge's configuration document, which answers once Bridge is ready. */ +export const BRIDGE_CONFIG = `${BRIDGE}/api/v1/ceremony/config` +export const NOTARY = 'http://localhost:4687' diff --git a/ts/apps/dev/package.json b/ts/apps/dev/package.json index f3647ab0..8bf70994 100644 --- a/ts/apps/dev/package.json +++ b/ts/apps/dev/package.json @@ -13,7 +13,9 @@ "build:deps": "pnpm --filter '@libid/dev^...' build", "build": "vite build", "typecheck": "tsc --noEmit", - "test:e2e": "playwright test" + "test": "node --test e2e/*.test.ts", + "test:e2e": "playwright test", + "test:live": "playwright test -c playwright.live.config.ts" }, "dependencies": { "@libid/ceremony": "workspace:^", @@ -24,6 +26,7 @@ "devDependencies": { "@playwright/test": "catalog:", "@types/node": "catalog:", + "otpauth": "^9.4.0", "typescript": "catalog:", "vite": "catalog:" } diff --git a/ts/apps/dev/playwright.live.config.ts b/ts/apps/dev/playwright.live.config.ts new file mode 100644 index 00000000..76b5bc1d --- /dev/null +++ b/ts/apps/dev/playwright.live.config.ts @@ -0,0 +1,63 @@ +import { defineConfig, devices } from '@playwright/test' +import { checkSettings, env, loadEnvFile, runnablePlatforms, settingPath } from './e2e/settings.ts' +import { APP_ORIGIN } from './local.ts' + +// Test secrets from the file LIBID_TEST_ENV_FILE names; exported values win. +// The runner process checks them once; its workers load this file too. +loadEnvFile() +if (process.env.TEST_WORKER_INDEX === undefined) checkSettings() +const stackLogs = env('LIBID_STACK_LOGS') && settingPath(env('LIBID_STACK_LOGS')!) +const proxy = env('LIBID_TEST_PROXY') +const ci = !!env('CI') + +/** + * The stack's environment: only what the build, Docker and services.ts need. + * Playwright merges in its own environment, so every other variable, the test + * accounts' secrets among them, is set empty. + */ +const KEPT = /^(PATH|HOME|USER|TMPDIR|LANG|CI|PNPM_HOME|XDG_\w+|DOCKER_\w+)$/ +const stackEnv = Object.fromEntries( + Object.entries(process.env).map(([name, value]) => [name, KEPT.test(name) ? (value ?? '') : '']), +) + +/** Live: real platforms, real notary, real Bridge; no chain. */ +export default defineConfig({ + testDir: 'e2e', + testMatch: '*.live.spec.ts', + workers: 1, + // A test: page load and Ready (2 min), the popup (1 min), its driver (3 min) + // and the outcome (8 min), with margin. + timeout: 15 * 60_000, + reporter: [['list']], + // Start the services and the app only when a test will run, then stop both. + webServer: runnablePlatforms().length + ? { + // exec: Playwright's SIGTERM reaches services.ts, which brings the stack + // down. LIBID_STACK_LOGS takes all of its output, the build's included. + command: `${stackLogs ? 'exec >>"$LIBID_STACK_LOGS" 2>&1; ' : ''}pnpm build:deps && exec node services.ts --app`, + env: { ...stackEnv, ...(stackLogs && { LIBID_STACK_LOGS: stackLogs }) }, + url: APP_ORIGIN, + reuseExistingServer: false, + timeout: 10 * 60_000, + gracefulShutdown: { signal: 'SIGTERM', timeout: 2 * 60_000 }, + } + : undefined, + use: { + ...devices['Desktop Chrome'], + baseURL: APP_ORIGIN, + headless: !env('HEADED'), + // A hang ends on the action that hangs, not on the test's timeout. + actionTimeout: 30_000, + navigationTimeout: 60_000, + // Automation X and Google can see makes them challenge; see e2e/person.ts. + launchOptions: { args: ['--disable-blink-features=AutomationControlled'] }, + // LIBID_TEST_PROXY routes the browser's traffic, loopback aside, through a + // proxy: the platforms then see the proxy's address instead of this host's. + ...(proxy && { proxy: { server: proxy, bypass: 'localhost,127.0.0.1,[::1]' } }), + // A trace records every fill and cookie, the test accounts' password and + // sessions included, so CI keeps screenshots only. + trace: ci ? 'off' : 'retain-on-failure', + screenshot: 'only-on-failure', + video: ci ? 'off' : 'retain-on-failure', + }, +}) diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts index 682fe3c3..576ca09e 100644 --- a/ts/apps/dev/services.ts +++ b/ts/apps/dev/services.ts @@ -1,15 +1,52 @@ // Start the real Bridge, notary and emitted CCDP; no OAuth mocks. import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' +import { connect } from 'node:net' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { createServer as createViteServer, type ViteDevServer } from 'vite' +import { APP_ORIGIN, BRIDGE_CONFIG } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) // Distinct Compose ownership for each checkout. No shared container names. const project = `libid-dev-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] +/** Whether something on this host accepts connections on `port`. */ +const listening = (port: number) => + new Promise((resolve) => { + const socket = connect({ port, host: '127.0.0.1', timeout: 1000 }) + const done = (open: boolean) => { + socket.destroy() + resolve(open) + } + socket.once('connect', () => done(true)) + socket.once('timeout', () => done(false)) + socket.once('error', () => done(false)) + }) +// Refuse to start beside another stack: readiness could pass against it. +if (!process.argv.includes('--ccdp')) { + let ports: number[] + try { + const config = JSON.parse( + execFileSync('docker', [...composeArgs, 'config', '--format', 'json'], { encoding: 'utf8' }), + ) as { services: Record } + ports = Object.values(config.services).flatMap((service) => + (service.ports ?? []).map(({ published }) => Number(published)).filter(Boolean), + ) + } catch { + console.error('Could not run Docker Compose. Install Docker with Compose and start its engine.') + process.exit(1) + } + const open = await Promise.all(ports.map(listening)) + const taken = ports.filter((_, index) => open[index]) + if (taken.length) { + console.error( + `Already in use on 127.0.0.1: port ${taken.join(', ')}. Stop what holds it (a running \`pnpm dev\`? see docker ps) and retry.`, + ) + process.exit(1) + } +} // Rebuild the shared distribution; immutable assets reuse the build cache. execFileSync( 'pnpm', @@ -70,8 +107,9 @@ if (process.argv.includes('--app')) { console.info('Waiting for Bridge readiness before starting the frontend…') while (!stopping) { try { - const response = await fetch('http://127.0.0.1:4682/api/v1/ceremony/config', { - headers: { Origin: 'http://localhost:4691' }, + // Docker publishes on 127.0.0.1 only. + const response = await fetch(BRIDGE_CONFIG.replace('localhost', '127.0.0.1'), { + headers: { Origin: APP_ORIGIN }, redirect: 'error', signal: AbortSignal.timeout(1000), }) diff --git a/ts/apps/dev/src/app.fixtures.ts b/ts/apps/dev/src/app.fixtures.ts index f6682028..af8e479f 100644 --- a/ts/apps/dev/src/app.fixtures.ts +++ b/ts/apps/dev/src/app.fixtures.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { type BrowserContext, expect, type Locator, type Page } from '@playwright/test' import { buildGooglePublicInputs } from '../../../packages/ceremony/src/platforms/google/1/validation.js' +import { BRIDGE_CONFIG } from '../local.ts' // Helpers for app.spec.ts: the Bridge and app, the run history, and the popup-side fixtures. @@ -22,7 +23,7 @@ declare global { } } -export const configUrl = 'http://localhost:4682/api/v1/ceremony/config' +export const configUrl = BRIDGE_CONFIG // The Bridge selects CCDP, so tests use their own origin, apart from the development one. export const ccdp = 'http://localhost:4684' export const config = { @@ -76,9 +77,13 @@ export async function openApp( await serveBridge(page, platforms) await serveVersions(page, versions) await page.goto('/') - await expect(page.locator('#status')).toContainText('Ready.') + await expectReady(page) } +/** Waits for the app to report itself ready to launch, up to `timeout` ms. */ +export const expectReady = (page: Page, timeout?: number) => + expect(page.locator('#status')).toContainText('Ready.', { timeout }) + export const launchButton = (page: Page, name: string) => page.getByRole('button', { name, exact: true }) export const launchButtons = (page: Page) => page.locator('#platforms').getByRole('button') diff --git a/ts/apps/dev/src/app.spec.ts b/ts/apps/dev/src/app.spec.ts index 5bb4d6cf..c6530d02 100644 --- a/ts/apps/dev/src/app.spec.ts +++ b/ts/apps/dev/src/app.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test' +import { NOTARY } from '../local.ts' import { ccdp, closeButton, @@ -52,7 +53,7 @@ test('unavailable Bridge disables launch; reload loads compatible platforms', as available = true await page.reload() await expect(page.getByRole('status')).toContainText('Ready.') - await expect(page.locator('dl')).toContainText('http://localhost:4687') + await expect(page.locator('dl')).toContainText(NOTARY) await expect(page.locator('dl')).not.toContainText('Ledger') await expect(launchButtons(page)).toHaveText(['Google']) await expect(launchButton(page, 'Google')).toBeEnabled() diff --git a/ts/apps/dev/src/app.ts b/ts/apps/dev/src/app.ts index f0ebd3a4..8a4d48b0 100644 --- a/ts/apps/dev/src/app.ts +++ b/ts/apps/dev/src/app.ts @@ -10,21 +10,21 @@ import { import type { LedgerId } from '@libid/ledger' import { PopupWindow } from '@libid/popup' import { sha256 } from '@noble/hashes/sha2.js' +import { BRIDGE, NOTARY } from '../local.ts' declare global { interface Window { results: Map } } -const oauthBridge = 'http://localhost:4682' /** A synthetic ledger routed to the local notary. */ const ledger: LedgerId = { - notaryAddress: () => 'http://localhost:4687', + notaryAddress: () => NOTARY, } const platforms = document.querySelector('#platforms')! const status = document.querySelector('#status')! window.results = new Map() -document.querySelector('#bridge')!.textContent = oauthBridge +document.querySelector('#bridge')!.textContent = BRIDGE document.querySelector('#notary')!.textContent = ledger.notaryAddress() const names: Record = { google: 'Google', x: 'X', github: 'GitHub' } /** A new `tag` element with `properties` assigned. */ @@ -35,7 +35,7 @@ const element = ( let client: CCDPClient | undefined async function initialize() { try { - client = await createCCDPClient({ oauthBridge }) + client = await createCCDPClient({ oauthBridge: BRIDGE }) platforms.replaceChildren( ...client.enabledPlatforms.map((platform) => { const launch = document.createElement('a') diff --git a/ts/apps/dev/tsconfig.json b/ts/apps/dev/tsconfig.json index 3ca17d93..92a09705 100644 --- a/ts/apps/dev/tsconfig.json +++ b/ts/apps/dev/tsconfig.json @@ -6,5 +6,5 @@ "allowImportingTsExtensions": true, "types": ["node"] }, - "include": ["*.ts", "src"] + "include": ["*.ts", "src", "e2e"] } diff --git a/ts/apps/dev/vite.config.ts b/ts/apps/dev/vite.config.ts index fb977889..d1bc06e1 100644 --- a/ts/apps/dev/vite.config.ts +++ b/ts/apps/dev/vite.config.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' +import { APP_PORT } from './local.ts' const root = fileURLToPath(new URL('.', import.meta.url)) const directory = join(root, '.cache/dev') @@ -9,7 +10,7 @@ export default defineConfig({ cacheDir: join(directory, 'vite'), server: { host: 'localhost', - port: 4691, + port: APP_PORT, strictPort: true, }, build: { outDir: join(directory, 'app'), emptyOutDir: true }, diff --git a/ts/biome.json b/ts/biome.json index 41d790c5..bbdf09ec 100644 --- a/ts/biome.json +++ b/ts/biome.json @@ -12,6 +12,7 @@ "!**/playwright-report", "!**/coverage", "!apps/dev/.cache", + "!apps/dev/e2e/stealth.js", "!.cache" ] }, diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 203d5849..a1822555 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@types/node': specifier: 'catalog:' version: 26.6.5 + otpauth: + specifier: ^9.4.0 + version: 9.5.2 typescript: specifier: 'catalog:' version: 7.0.2 @@ -1021,6 +1024,9 @@ packages: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} + otpauth@9.5.2: + resolution: {integrity: sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w==} + oxlint@1.87.0: resolution: {integrity: sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1779,6 +1785,10 @@ snapshots: obug@2.2.1: {} + otpauth@9.5.2: + dependencies: + '@noble/hashes': 2.4.0 + oxlint@1.87.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.87.0