passkeys.spec.ts

5.9 KB · raw

1import { test, expect, type BrowserContext, type Page } from "@playwright/test";2import { createServer, type Server } from "http";3import { readFile } from "fs/promises";4import { fileURLToPath } from "url";5import { dirname, join } from "path";6import os from "os";7import fs from "fs/promises";89const __filename = fileURLToPath(import.meta.url);10const __dirname = dirname(__filename);11const EXTENSION_PATH = join(__dirname, "..", "dist-chrome");12const FIXTURE_PATH = join(__dirname, "fixtures", "passkeys-smoke.html");1314async function startFixtureServer(): Promise<{ server: Server; url: string }> {15  const html = await readFile(FIXTURE_PATH, "utf-8");16  const server = createServer((_, res) => {17    res.writeHead(200, { "content-type": "text/html; charset=utf-8" });18    res.end(html);19  });2021  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));22  const address = server.address();23  if (!address || typeof address === "string") {24    throw new Error("Unable to determine fixture server port");25  }2627  return { server, url: `http://localhost:${address.port}` };28}2930async function stopFixtureServer(server: Server): Promise<void> {31  await new Promise<void>((resolve, reject) => {32    server.close((error) => (error ? reject(error) : resolve()));33  });34}3536async function makeContext() {37  const { chromium } = await import("playwright");38  const userDataDir = join(39    os.tmpdir(),40    `peach-passkeys-${Date.now()}-${Math.random().toString(36).slice(2)}`41  );42  await fs.mkdir(userDataDir, { recursive: true });43  return chromium.launchPersistentContext(userDataDir, {44    headless: false,45    args: [46      `--load-extension=${EXTENSION_PATH}`,47      `--disable-extensions-except=${EXTENSION_PATH}`,48      "--no-sandbox",49      "--disable-setuid-sandbox",50    ],51  });52}5354async function waitForExtensionId(ctx: BrowserContext, maxWait = 15_000): Promise<string> {55  const start = Date.now();56  let sw = ctx.serviceWorkers()[0];57  while (!sw && Date.now() - start < maxWait) {58    await new Promise((resolve) => setTimeout(resolve, 250));59    sw = ctx.serviceWorkers()[0];60  }61  if (!sw) throw new Error(`No service worker after ${maxWait}ms`);62  return new URL(sw.url()).host;63}6465async function openPopup(ctx: BrowserContext, extId: string): Promise<Page> {66  const popup = await ctx.newPage();67  await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);68  await popup.waitForTimeout(1500);69  return popup;70}7172async function createVaultAndEnablePasskeys(popup: Page) {73  // Drive setup over messages in a single service-worker activation: create the74  // vault, UNLOCK (which persists the session key so the proxy sees an unlocked75  // vault on its next wake), enable the provider, and disable lock-on-close so76  // the approval popup's lifecycle can't lock the vault mid-ceremony. The old77  // UI-click setup was flaky because createVault alone does not persist a78  // session key, so a respawned worker handled the ceremony locked.79  const result = await popup.evaluate(async () => {80    const send = (m: object) =>81      new Promise<{ ok?: boolean }>((res) => chrome.runtime.sendMessage(m, res));82    const create = await send({83      type: "CREATE_VAULT",84      payload: { password: "passkeysmoke123" },85    });86    const unlock = await send({87      type: "UNLOCK",88      payload: { password: "passkeysmoke123" },89    });90    await send({ type: "UPDATE_SETTINGS", payload: { lockOnClose: false } });91    const enable = await send({92      type: "SET_PASSKEY_PROVIDER_ENABLED",93      payload: { enabled: true },94    });95    return { create, unlock, enable };96  });9798  expect(result.create.ok).toBe(true);99  expect(result.unlock.ok).toBe(true);100  expect(result.enable.ok).toBe(true);101}102103async function approvePopupRequest(104  ctx: BrowserContext,105  extId: string,106  buttonName: RegExp,107  expectedTitle: RegExp108) {109  // Open a fresh popup to catch the pending-request approval surface. The popup110  // is left open (closed at context teardown); lock-on-close is disabled during111  // setup so its eventual disconnect cannot lock the vault or deny the request.112  const popup = await openPopup(ctx, extId);113  await expect(popup.getByRole("heading", { name: expectedTitle })).toBeVisible({114    timeout: 15_000,115  });116  await popup.getByRole("button", { name: buttonName }).click();117}118119test.describe("Passkey Smoke", () => {120  let ctx: BrowserContext;121  let server: Server;122  let url: string;123124  test.beforeEach(async () => {125    ({ server, url } = await startFixtureServer());126    ctx = await makeContext();127  });128129  test.afterEach(async () => {130    if (ctx) await ctx.close();131    if (server) await stopFixtureServer(server);132  });133134  test("create() and get() resolve through the Peach extension provider", async () => {135    const extId = await waitForExtensionId(ctx);136    const popup = await openPopup(ctx, extId);137    await createVaultAndEnablePasskeys(popup);138139    const page = await ctx.newPage();140    await page.goto(url);141142    await page.getByRole("button", { name: /create passkey/i }).click();143    await approvePopupRequest(ctx, extId, /save passkey/i, /save a passkey\?/i);144145    const createStatus = page.locator("#create-status");146    await expect(createStatus).toHaveAttribute("data-state", "ok", {147      timeout: 15_000,148    });149    await expect(createStatus).toContainText("Created");150151    await page.getByRole("button", { name: /authenticate/i }).click();152    await approvePopupRequest(ctx, extId, /sign in/i, /sign in with passkey\?/i);153154    const getStatus = page.locator("#get-status");155    await expect(getStatus).toHaveAttribute("data-state", "ok", {156      timeout: 15_000,157    });158    await expect(getStatus).toContainText("Authenticated");159160    const refreshedPopup = await openPopup(ctx, extId);161    await refreshedPopup.locator(".peach-search-input").click();162    await expect(refreshedPopup.locator("text=Peach Passkey Smoke").first()).toBeVisible({163      timeout: 10_000,164    });165    await expect(refreshedPopup.locator("text=passkey").first()).toBeVisible();166  });167});