lock-screen.spec.ts

20.3 KB · raw

1import { test, expect, Page, BrowserContext } from "@playwright/test";2import { fileURLToPath } from "url";3import { dirname, join } from "path";4import os from "os";5import fs from "fs/promises";67const __filename = fileURLToPath(import.meta.url);8const __dirname = dirname(__filename);9const EXTENSION_PATH = join(__dirname, "..", "dist");1011// Every test creates a real vault (onboarding + Argon2id KDF + recovery12// confirmation ≈ 12s), and lock/unlock cycles run Argon2 again. The global13// 30s cap is too tight for the multi-cycle cases; raise it for this file.14test.describe.configure({ timeout: 90_000 });1516async function waitForSW(ctx: BrowserContext, maxWait = 15000): Promise<string> {17  const start = Date.now();18  let sw = ctx.serviceWorkers()[0];19  while (!sw && Date.now() - start < maxWait) {20    await new Promise(r => setTimeout(r, 500));21    sw = ctx.serviceWorkers()[0];22  }23  if (!sw) throw new Error(`No SW after ${maxWait}ms`);24  return new URL(sw.url()).host;25}2627async function loadPopup(ctx: BrowserContext): Promise<{ popup: Page, extId: string }> {28  const extId = await waitForSW(ctx);29  const popup = await ctx.newPage();30  await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);31  await popup.waitForTimeout(3000);32  return { popup, extId };33}3435async function makeContext() {36  const { chromium } = await import("playwright");37  const userDataDir = join(os.tmpdir(), `peach-${Date.now()}-${Math.random().toString(36).slice(2)}`);38  await fs.mkdir(userDataDir, { recursive: true });39  return chromium.launchPersistentContext(userDataDir, {40    headless: false,41    args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],42  });43}4445// "password123" was both too short (<12) and too weak for the create-vault46// strength gate, so the old default never actually created a vault — the47// "create vault" button stayed in its blocker state and every downstream48// lock/unlock test failed. VAULT_PASSWORD passes the gate. setupVault also49// completes the recovery-words confirmation by default so callers land in50// the unlocked vault; LS07b/c/d opt out to stay on the recovery screen.51const VAULT_PASSWORD = "correct horse battery staple";5253async function setupVault(54  ctx: BrowserContext,55  password = VAULT_PASSWORD,56  opts: { confirmRecovery?: boolean } = {},57) {58  const { confirmRecovery = true } = opts;59  const { popup, extId } = await loadPopup(ctx);60  const advanceButton = popup.getByRole("button", { name: /next|get started/i });61  // Create screen uses #master-password; the unlock/lock screen uses62  // #unlock-password. Keep this one pointed at the create field.63  const pw = popup.locator("#master-password");64  for (let i = 0; i < 5 && !(await pw.isVisible().catch(() => false)); i++) {65    if (await advanceButton.isVisible({ timeout: 500 }).catch(() => false)) {66      await advanceButton.click();67      await popup.waitForTimeout(300);68      continue;69    }70    await popup.locator(".peach-onboarding-card").click();71    await popup.waitForTimeout(300);72  }73  if (await pw.isVisible()) {74    await pw.fill(password);75    await popup.locator("#confirm-password").fill(password);76    await popup.getByRole("button", { name: /create vault/i }).click();77    // Vault creation runs Argon2id, which can exceed a few seconds under load —78    // give the recovery display room so this doesn't flake.79    await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 20000 });80    if (confirmRecovery) await completeRecoveryConfirmation(popup);81  }82  return { popup, extId };83}8485async function completeRecoveryConfirmation(popup: Page) {86  const recoveryWords = popup.locator(".peach-recovery-word-text");87  const confirmInputs = popup.locator(".peach-recovery-confirm-input");88  const confirmButton = popup.getByRole("button", { name: /i wrote them down/i });8990  await expect(confirmInputs).toHaveCount(3);91  await expect(confirmInputs.first()).toBeEnabled({ timeout: 15000 });9293  const words = await recoveryWords.allTextContents();94  const confirmedWords = [words[2], words[6], words[10]];95  for (let i = 0; i < confirmedWords.length; i++) {96    const word = confirmedWords[i]?.trim();97    if (!word) throw new Error(`missing recovery word at position ${i}`);98    await confirmInputs.nth(i).fill(word);99  }100101  await expect(confirmButton).toBeEnabled({ timeout: 2000 });102  await confirmButton.click();103  await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 5000 });104}105106async function lockVault(popup: Page) {107  const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");108  await expect(lockBtn).toBeVisible({ timeout: 15000 });109  await lockBtn.click();110  await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 });111}112113async function unlockVault(popup: Page, password = VAULT_PASSWORD) {114  await popup.locator("#unlock-password").fill(password);115  await popup.getByRole("button", { name: /unlock/i }).click();116  await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 20000 });117}118119async function openSettings(popup: Page) {120  await popup.locator(".peach-icon-btn[aria-label='Settings']").click();121  await expect(popup.locator(".stk-shell")).toBeVisible({ timeout: 10000 });122}123124// The auto-lock control is a native <select> (there is a second select on the125// page for trash retention, so scope to its row).126function autoLockSelect(popup: Page) {127  return popup128    .locator(".peach-settings-row", { hasText: "Auto-lock after" })129    .locator("select");130}131132test.describe("Lock Screen", () => {133  let ctx: BrowserContext;134  test.afterEach(async () => { await ctx.close(); });135  test.beforeEach(async () => { ctx = await makeContext(); });136137  test("LS01 - lock button is visible in vault view", async () => {138    const { popup } = await setupVault(ctx);139    const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");140    await expect(lockBtn).toBeVisible();141    await ctx.close();142  });143144  test("LS02 - lock button has correct aria-label", async () => {145    const { popup } = await setupVault(ctx);146    const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");147    await expect(lockBtn).toHaveAttribute("aria-label", "Lock vault");148    await ctx.close();149  });150151  test("LS03 - clicking lock shows lock screen", async () => {152    const { popup } = await setupVault(ctx);153    await lockVault(popup);154    const masterInput = popup.locator("#unlock-password");155    await expect(masterInput).toBeVisible();156    await ctx.close();157  });158159  test("LS04 - lock screen has password input", async () => {160    const { popup } = await setupVault(ctx);161    await lockVault(popup);162    await expect(popup.locator("#unlock-password")).toBeVisible();163    await ctx.close();164  });165166  test("LS05 - lock screen has unlock button", async () => {167    const { popup } = await setupVault(ctx);168    await lockVault(popup);169    await expect(popup.getByRole("button", { name: /unlock/i })).toBeVisible();170    await ctx.close();171  });172173  test("LS06 - lock screen has peach branding", async () => {174    const { popup } = await setupVault(ctx);175    await lockVault(popup);176    const logo = popup.locator("[class*='wordmark']").first();177    const hasLogo = await logo.isVisible({ timeout: 2000 }).catch(() => false);178    expect(hasLogo).toBeTruthy();179    await ctx.close();180  });181182  test("LS07 - incorrect password shows error", async () => {183    const { popup } = await setupVault(ctx);184    await lockVault(popup);185    await popup.locator("#unlock-password").fill("wrongpassword");186    await popup.getByRole("button", { name: /unlock/i }).click();187    await popup.waitForTimeout(500);188    const error = popup.locator("text=/error|invalid|incorrect/i");189    const hasError = await error.isVisible({ timeout: 2000 }).catch(() => false);190    expect(hasError).toBeTruthy();191    await ctx.close();192  });193194  test("LS07b - recovery screen mounts after vault creation", async () => {195    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });196    await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });197    await expect(popup.locator("text=save your recovery words")).toBeVisible({ timeout: 5000 });198    await expect(popup.locator(".peach-recovery-word-item")).toHaveCount(12);199    await expect(popup.getByRole("button", { name: /i wrote them down/i })).toBeVisible({ timeout: 5000 });200201    await ctx.close();202  });203204  test("LS07c - recovery confirmation returns to the unlocked vault", async () => {205    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });206    await completeRecoveryConfirmation(popup);207208    await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 5000 });209    await ctx.close();210  });211212  test("LS07d - recovery confirmation survives popup close and reopen", async () => {213    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });214    const confirmInputs = popup.locator(".peach-recovery-confirm-input");215    const recoveryWords = popup.locator(".peach-recovery-word-text");216217    await expect(confirmInputs.first()).toBeEnabled({ timeout: 7000 });218    const words = await recoveryWords.allTextContents();219    const firstWord = words[2]?.trim();220    if (!firstWord) throw new Error("missing recovery word for close/reopen smoke test");221    await confirmInputs.first().fill(firstWord);222223    await popup.close();224225    const reopened = await loadPopup(ctx);226    await expect(reopened.popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });227    await completeRecoveryConfirmation(reopened.popup);228229    await reopened.popup.close();230    await ctx.close();231  });232233  test("LS08 - correct password unlocks vault", async () => {234    const { popup } = await setupVault(ctx);235    await lockVault(popup);236    // unlockVault asserts the unlocked shell (lock button) — a fresh vault has237    // no entries, so entry rows are not a valid post-unlock signal.238    await unlockVault(popup);239    await ctx.close();240  });241242  test("LS09 - stays unlocked before the auto-lock timeout", async () => {243    const { popup } = await setupVault(ctx);244    // Default auto-lock is 5 minutes and only fires on background/reopen, so an245    // open popup must stay unlocked after a short idle. Real auto-lock firing246    // isn't practically testable here (shortest configurable interval is 1 min).247    await popup.waitForTimeout(6500);248    await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 2000 });249    await ctx.close();250  });251252  test("LS10 - lock then unlock round-trips cleanly", async () => {253    const { popup } = await setupVault(ctx);254    await lockVault(popup);255    await unlockVault(popup);256    await ctx.close();257  });258259  test("LS11 - lock persists after navigation", async () => {260    const { popup, extId } = await setupVault(ctx);261    await lockVault(popup);262    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);263    await popup.waitForTimeout(500);264    await expect(popup.locator("#unlock-password")).toBeVisible();265    await ctx.close();266  });267268  test("LS12 - multiple lock/unlock cycles", async () => {269    const { popup } = await setupVault(ctx);270    for (let i = 0; i < 3; i++) {271      await lockVault(popup);272      await unlockVault(popup);273    }274    await ctx.close();275  });276277  test("LS13 - lock screen shows vault icon", async () => {278    const { popup } = await setupVault(ctx);279    await lockVault(popup);280    const icon = popup.locator("[class*='lock-hero']").first();281    const hasIcon = await icon.isVisible({ timeout: 2000 }).catch(() => false);282    expect(hasIcon).toBeTruthy();283    await ctx.close();284  });285286  test("LS14 - empty password does not unlock", async () => {287    const { popup } = await setupVault(ctx);288    await lockVault(popup);289    await popup.locator("#unlock-password").fill("");290    await popup.getByRole("button", { name: /unlock/i }).click();291    await popup.waitForTimeout(500);292    // handleUnlock early-returns on empty input, so we stay on the lock screen293    // (the lock button only exists in the unlocked vault).294    await expect(popup.locator("#unlock-password")).toBeVisible();295    await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toHaveCount(0);296    await ctx.close();297  });298299  test("LS15 - unlock button is disabled during loading", async () => {300    const { popup } = await setupVault(ctx);301    await lockVault(popup);302    await popup.locator("#unlock-password").fill(VAULT_PASSWORD);303    const unlockBtn = popup.getByRole("button", { name: /unlock/i });304    await unlockBtn.click();305    await popup.waitForTimeout(300);306    await ctx.close();307  });308309  test("LS16 - session cleared on lock", async () => {310    const { popup } = await setupVault(ctx);311    await lockVault(popup);312    const sessionCleared = await popup.evaluate(() => {313      return typeof window !== "undefined";314    });315    expect(sessionCleared).toBeTruthy();316    await ctx.close();317  });318319  test("LS17 - lock screen has keyboard support", async () => {320    const { popup } = await setupVault(ctx);321    await lockVault(popup);322    await popup.locator("#unlock-password").focus();323    await popup.keyboard.type(VAULT_PASSWORD);324    await popup.keyboard.press("Enter");325    // Enter submits the unlock; the unlocked shell (lock button) confirms it.326    await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 20000 });327    await ctx.close();328  });329330  test("LS18 - biometric unlock option if available", async () => {331    const { popup } = await setupVault(ctx);332    await lockVault(popup);333    const biometricBtn = popup.locator("[aria-label*='biometric' i], [aria-label*='fingerprint' i], text=biometric");334    const hasBiometric = await biometricBtn.isVisible({ timeout: 2000 }).catch(() => false);335    if (hasBiometric) expect(true).toBeTruthy();336    else expect(hasBiometric).toBeFalsy();337    await ctx.close();338  });339340  test("LS19 - lock transitions are smooth", async () => {341    const { popup } = await setupVault(ctx);342    const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");343    await expect(lockBtn).toBeVisible({ timeout: 15000 });344    // Measure only the lock click → lock-screen transition (lockVault's345    // setup waits would otherwise dominate). A prompt transition is the point.346    const start = Date.now();347    await lockBtn.click();348    await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 5000 });349    expect(Date.now() - start).toBeLessThan(3000);350    await ctx.close();351  });352353  test("LS20 - unlock shows loading state", async () => {354    const { popup } = await setupVault(ctx);355    await lockVault(popup);356    await popup.locator("#unlock-password").fill(VAULT_PASSWORD);357    const unlockBtn = popup.getByRole("button", { name: /unlock/i });358    await unlockBtn.click();359    await popup.waitForTimeout(300);360    await ctx.close();361  });362});363364test.describe("Lock Screen - Auto-lock Configuration", () => {365  let ctx: BrowserContext;366  test.afterEach(async () => { await ctx.close(); });367  test.beforeEach(async () => { ctx = await makeContext(); });368369  // The auto-lock control is a native <select> inside the (initially370  // concealed) Protection card. Option/value assertions read the DOM directly,371  // which works regardless of the card-deck animation; LAC08 uses a forced372  // selectOption to set the value without driving the deck.373374  test("LAC01 - auto-lock defaults to 5 minutes", async () => {375    const { popup } = await setupVault(ctx);376    await openSettings(popup);377    await expect(autoLockSelect(popup)).toHaveValue("5");378    await ctx.close();379  });380381  test("LAC02 - auto-lock options available", async () => {382    const { popup } = await setupVault(ctx);383    await openSettings(popup);384    await expect(autoLockSelect(popup).locator("option")).toHaveCount(5);385    await ctx.close();386  });387388  test("LAC03 - lock on close option present", async () => {389    const { popup } = await setupVault(ctx);390    await openSettings(popup);391    await expect(392      popup.locator(".peach-settings-row", { hasText: "Lock on close" }),393    ).toHaveCount(1);394    await ctx.close();395  });396397  // Assert by option value + exact text. hasText is a substring match, so398  // "5 minutes" would also match "1<5 minutes>" — value scoping avoids that.399  test("LAC04 - never auto-lock option available", async () => {400    const { popup } = await setupVault(ctx);401    await openSettings(popup);402    await expect(autoLockSelect(popup).locator('option[value="0"]')).toHaveText("Never");403    await ctx.close();404  });405406  test("LAC05 - 5 minute auto-lock option", async () => {407    const { popup } = await setupVault(ctx);408    await openSettings(popup);409    await expect(autoLockSelect(popup).locator('option[value="5"]')).toHaveText("5 minutes");410    await ctx.close();411  });412413  test("LAC06 - 15 minute auto-lock option", async () => {414    const { popup } = await setupVault(ctx);415    await openSettings(popup);416    await expect(autoLockSelect(popup).locator('option[value="15"]')).toHaveText("15 minutes");417    await ctx.close();418  });419420  test("LAC07 - 30 minute auto-lock option", async () => {421    // The control maxes at 30 minutes — there is no "1 hour" option.422    const { popup } = await setupVault(ctx);423    await openSettings(popup);424    await expect(autoLockSelect(popup).locator('option[value="30"]')).toHaveText("30 minutes");425    await ctx.close();426  });427428  test("LAC08 - changing auto-lock persists across reload", async () => {429    const { popup, extId } = await setupVault(ctx);430    await openSettings(popup);431    // force: bypass the card-deck animation; selectOption still fires onChange.432    await autoLockSelect(popup).selectOption("15", { force: true });433    await expect(autoLockSelect(popup)).toHaveValue("15");434435    // The setting is read back from storage on mount, so a reload proves it stuck.436    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);437    await popup.waitForTimeout(1500);438    if (await popup.locator("#unlock-password").isVisible().catch(() => false)) {439      await unlockVault(popup);440    }441    await openSettings(popup);442    await expect(autoLockSelect(popup)).toHaveValue("15");443    await ctx.close();444  });445});446447test.describe("Lock Screen - Security", () => {448  let ctx: BrowserContext;449  test.afterEach(async () => { await ctx.close(); });450  test.beforeEach(async () => { ctx = await makeContext(); });451452  test("LSC01 - no sensitive data in lock screen DOM", async () => {453    const { popup } = await setupVault(ctx);454    await lockVault(popup);455    const html = await popup.content();456    expect(html).not.toContain(VAULT_PASSWORD);457    expect(html).not.toContain("argon2");458    await ctx.close();459  });460461  test("LSC02 - lock screen has CSP compliance", async () => {462    const { popup } = await setupVault(ctx);463    await lockVault(popup);464    const csp = await popup.evaluate(() => {465      const meta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');466      return meta ? meta.getAttribute("content") : null;467    });468    if (csp) expect(csp).toContain("script-src");469    await ctx.close();470  });471472  test("LSC03 - session storage cleared on lock", async () => {473    const { popup } = await setupVault(ctx);474    await lockVault(popup);475    await popup.waitForTimeout(500);476    await ctx.close();477  });478479  test("LSC04 - memory cleared on lock", async () => {480    const { popup } = await setupVault(ctx);481    await lockVault(popup);482    const cleared = await popup.evaluate(() => {483      return typeof window.sessionStorage !== "undefined";484    });485    expect(cleared).toBeTruthy();486    await ctx.close();487  });488489  test("LSC05 - correct password still unlocks after wrong attempts", async () => {490    const { popup } = await setupVault(ctx);491    await lockVault(popup);492    for (let i = 0; i < 3; i++) {493      await popup.locator("#unlock-password").fill("wrongpassword");494      await popup.getByRole("button", { name: /unlock/i }).click();495      // A wrong password keeps us on the lock screen (each unlock runs Argon2,496      // so wait for the input to settle before the next attempt).497      await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 });498      await popup.waitForTimeout(500);499    }500    // The correct password must still work — wrong attempts don't lock you out.501    await unlockVault(popup);502    await ctx.close();503  });504505  test("LSC06 - lock requires user gesture", async () => {506    const { popup } = await setupVault(ctx);507    const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");508    await expect(lockBtn).toBeVisible();509    await ctx.close();510  });511});