passkeys.spec.ts

5.9 KB · raw

1import { chromium, 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 userDataDir = join(38    os.tmpdir(),39    `peach-passkeys-${Date.now()}-${Math.random().toString(36).slice(2)}`40  );41  await fs.mkdir(userDataDir, { recursive: true });42  return chromium.launchPersistentContext(userDataDir, {43    headless: false,44    args: [45      `--load-extension=${EXTENSION_PATH}`,46      `--disable-extensions-except=${EXTENSION_PATH}`,47      "--no-sandbox",48      "--disable-setuid-sandbox",49    ],50  });51}5253async function waitForExtensionId(ctx: BrowserContext, maxWait = 15_000): Promise<string> {54  const start = Date.now();55  let sw = ctx.serviceWorkers()[0];56  while (!sw && Date.now() - start < maxWait) {57    await new Promise((resolve) => setTimeout(resolve, 250));58    sw = ctx.serviceWorkers()[0];59  }60  if (!sw) throw new Error(`No service worker after ${maxWait}ms`);61  return new URL(sw.url()).host;62}6364async function openPopup(ctx: BrowserContext, extId: string): Promise<Page> {65  const popup = await ctx.newPage();66  await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);67  await popup.waitForTimeout(1500);68  return popup;69}7071async function createVaultAndEnablePasskeys(popup: Page) {72  // Drive setup over messages in a single service-worker activation: create the73  // vault, UNLOCK (which persists the session key so the proxy sees an unlocked74  // vault on its next wake), enable the provider, and disable lock-on-close so75  // the approval popup's lifecycle can't lock the vault mid-ceremony. The old76  // UI-click setup was flaky because createVault alone does not persist a77  // session key, so a respawned worker handled the ceremony locked.78  const result = await popup.evaluate(async () => {79    const send = (m: object) =>80      new Promise<{ ok?: boolean }>((res) => chrome.runtime.sendMessage(m, res));81    const create = await send({82      type: "CREATE_VAULT",83      payload: { password: "passkeysmoke123" },84    });85    const unlock = await send({86      type: "UNLOCK",87      payload: { password: "passkeysmoke123" },88    });89    await send({ type: "UPDATE_SETTINGS", payload: { lockOnClose: false } });90    const enable = await send({91      type: "SET_PASSKEY_PROVIDER_ENABLED",92      payload: { enabled: true },93    });94    return { create, unlock, enable };95  });9697  expect(result.create.ok).toBe(true);98  expect(result.unlock.ok).toBe(true);99  expect(result.enable.ok).toBe(true);100}101102async function approvePopupRequest(103  ctx: BrowserContext,104  extId: string,105  buttonName: RegExp,106  expectedTitle: RegExp107) {108  // Open a fresh popup to catch the pending-request approval surface. The popup109  // is left open (closed at context teardown); lock-on-close is disabled during110  // setup so its eventual disconnect cannot lock the vault or deny the request.111  const popup = await openPopup(ctx, extId);112  await expect(popup.getByRole("heading", { name: expectedTitle })).toBeVisible({113    timeout: 15_000,114  });115  await popup.getByRole("button", { name: buttonName }).click();116}117118test.describe("Passkey Smoke", () => {119  let ctx: BrowserContext;120  let server: Server;121  let url: string;122123  test.beforeEach(async () => {124    ({ server, url } = await startFixtureServer());125    ctx = await makeContext();126  });127128  test.afterEach(async () => {129    if (ctx) await ctx.close();130    if (server) await stopFixtureServer(server);131  });132133  test("create() and get() resolve through the Peach extension provider", async () => {134    const extId = await waitForExtensionId(ctx);135    const popup = await openPopup(ctx, extId);136    await createVaultAndEnablePasskeys(popup);137138    const page = await ctx.newPage();139    await page.goto(url);140141    await page.getByRole("button", { name: /create passkey/i }).click();142    await approvePopupRequest(ctx, extId, /save passkey/i, /save a passkey\?/i);143144    const createStatus = page.locator("#create-status");145    await expect(createStatus).toHaveAttribute("data-state", "ok", {146      timeout: 15_000,147    });148    await expect(createStatus).toContainText("Created");149150    await page.getByRole("button", { name: /authenticate/i }).click();151    await approvePopupRequest(ctx, extId, /sign in/i, /sign in with passkey\?/i);152153    const getStatus = page.locator("#get-status");154    await expect(getStatus).toHaveAttribute("data-state", "ok", {155      timeout: 15_000,156    });157    await expect(getStatus).toContainText("Authenticated");158159    const refreshedPopup = await openPopup(ctx, extId);160    await refreshedPopup.getByLabel("Search vault entries").click();161    await expect(refreshedPopup.locator("text=Peach Passkey Smoke").first()).toBeVisible({162      timeout: 10_000,163    });164    await expect(refreshedPopup.locator("text=passkey").first()).toBeVisible();165  });166});