Repository Public
1import { execFileSync } from 'node:child_process';2import { randomBytes } from 'node:crypto';3import { dirname, join } from 'node:path';4import { fileURLToPath } from 'node:url';5import type { BrowserContext, Page } from '@playwright/test';67const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');8/** A one-time invitation link from the development stack's CLI. */9export function inviteUrl(email = `e2e-${randomBytes(4).toString('hex')}@example.test`, admin = false): { email: string; url: string; token: string } {10 const url = execFileSync(join(ROOT, 'scripts/dev-invite.sh'), admin ? [email, '--admin'] : [email], { cwd: ROOT }).toString().trim().split('\n').pop()!;11 return { email, url, token: url.split('/').pop()! };12}13/** Attach a virtual platform authenticator (user verifying, resident keys) to the page: passkey prompts are answered automatically. Chromium only. */14export async function virtualAuthenticator(context: BrowserContext, page: Page): Promise<void> {15 const cdp = await context.newCDPSession(page);16 await cdp.send('WebAuthn.enable');17 await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } });18}19/**20 * Create an account and a session without going through the screens (for specs that are not about sign-up): the21 * registration ceremony runs in the page with the raw WebAuthn API against the real endpoints.22 */23export async function apiSignUp(context: BrowserContext, page: Page, o: { admin?: boolean } = {}): Promise<{ email: string; recoveryCodes: string[] }> {24 const { email, token } = inviteUrl(undefined, o.admin);25 await virtualAuthenticator(context, page);26 await page.goto('/pwa/favicon.svg'); // any same-origin document27 const recoveryCodes = await page.evaluate(async (invite) => {28 const b64 = (b: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');29 const un = (s: string) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));30 const call = async (path: string, body: unknown) => { const r = await fetch(`/api/v1${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-SPD-CSRF': '1' }, body: JSON.stringify(body) }); if (!r.ok) throw new Error(`${path}: ${await r.text()}`); return r.json(); };31 const o = await call('/auth/register/options', { invite, displayName: 'E2E' });32 const pk = o.options;33 const cred = (await navigator.credentials.create({ publicKey: { ...pk, challenge: un(pk.challenge), user: { ...pk.user, id: un(pk.user.id) }, excludeCredentials: [] } })) as PublicKeyCredential;34 const r = cred.response as AuthenticatorAttestationResponse;35 const v = await call('/auth/register/verify', { invite, challengeId: o.challengeId, label: 'E2E', response: { id: cred.id, rawId: b64(cred.rawId), type: cred.type, clientExtensionResults: {}, response: { clientDataJSON: b64(r.clientDataJSON), attestationObject: b64(r.attestationObject), transports: ['internal'] } } });36 return v.recoveryCodes as string[];37 }, token);38 return { email, recoveryCodes };39}40