lock-screen.spec.ts

20.5 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");1011async function waitForSW(ctx: BrowserContext, maxWait = 15000): Promise<string> {12  const start = Date.now();13  let sw = ctx.serviceWorkers()[0];14  while (!sw && Date.now() - start < maxWait) {15    await new Promise(r => setTimeout(r, 500));16    sw = ctx.serviceWorkers()[0];17  }18  if (!sw) throw new Error(`No SW after ${maxWait}ms`);19  return new URL(sw.url()).host;20}2122async function loadPopup(ctx: BrowserContext): Promise<{ popup: Page, extId: string }> {23  const extId = await waitForSW(ctx);24  const popup = await ctx.newPage();25  await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);26  await popup.waitForTimeout(3000);27  return { popup, extId };28}2930async function makeContext() {31  const { chromium } = await import("playwright");32  const userDataDir = join(os.tmpdir(), `peach-${Date.now()}-${Math.random().toString(36).slice(2)}`);33  await fs.mkdir(userDataDir, { recursive: true });34  return chromium.launchPersistentContext(userDataDir, {35    headless: false,36    args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],37  });38}3940// "password123" was both too short (<12) and too weak for the create-vault41// strength gate, so the old default never actually created a vault — the42// "create vault" button stayed in its blocker state and every downstream43// lock/unlock test failed. VAULT_PASSWORD passes the gate. setupVault also44// completes the recovery-words confirmation by default so callers land in45// the unlocked vault; LS07b/c/d opt out to stay on the recovery screen.46const VAULT_PASSWORD = "correct horse battery staple";4748async function setupVault(49  ctx: BrowserContext,50  password = VAULT_PASSWORD,51  opts: { confirmRecovery?: boolean } = {},52) {53  const { confirmRecovery = true } = opts;54  const { popup, extId } = await loadPopup(ctx);55  const advanceButton = popup.getByRole("button", { name: /next|get started/i });56  const pw = popup.locator("#master-password");57  for (let i = 0; i < 5 && !(await pw.isVisible().catch(() => false)); i++) {58    if (await advanceButton.isVisible({ timeout: 500 }).catch(() => false)) {59      await advanceButton.click();60      await popup.waitForTimeout(300);61      continue;62    }63    await popup.locator(".peach-onboarding-card").click();64    await popup.waitForTimeout(300);65  }66  if (await pw.isVisible()) {67    await pw.fill(password);68    await popup.locator("#confirm-password").fill(password);69    await popup.getByRole("button", { name: /create vault/i }).click();70    await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });71    if (confirmRecovery) await completeRecoveryConfirmation(popup);72  }73  return { popup, extId };74}7576async function completeRecoveryConfirmation(popup: Page) {77  const recoveryWords = popup.locator(".peach-recovery-word-text");78  const confirmInputs = popup.locator(".peach-recovery-confirm-input");79  const confirmButton = popup.getByRole("button", { name: /i wrote them down/i });8081  await expect(confirmInputs).toHaveCount(3);82  await expect(confirmInputs.first()).toBeEnabled({ timeout: 7000 });8384  const words = await recoveryWords.allTextContents();85  const confirmedWords = [words[2], words[6], words[10]];86  for (let i = 0; i < confirmedWords.length; i++) {87    const word = confirmedWords[i]?.trim();88    if (!word) throw new Error(`missing recovery word at position ${i}`);89    await confirmInputs.nth(i).fill(word);90  }9192  await expect(confirmButton).toBeEnabled({ timeout: 2000 });93  await confirmButton.click();94  await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 5000 });95}9697async function lockVault(popup: Page) {98  await popup.locator(".peach-lock-btn[aria-label='Lock vault']").click();99  await popup.waitForTimeout(500);100}101102test.describe("Lock Screen", () => {103  let ctx: BrowserContext;104  test.afterEach(async () => { await ctx.close(); });105  test.beforeEach(async () => { ctx = await makeContext(); });106107  test("LS01 - lock button is visible in vault view", async () => {108    const { popup } = await setupVault(ctx);109    const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");110    await expect(lockBtn).toBeVisible();111    await ctx.close();112  });113114  test("LS02 - lock button has correct aria-label", async () => {115    const { popup } = await setupVault(ctx);116    const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");117    await expect(lockBtn).toHaveAttribute("aria-label", "Lock vault");118    await ctx.close();119  });120121  test("LS03 - clicking lock shows lock screen", async () => {122    const { popup } = await setupVault(ctx);123    await lockVault(popup);124    const masterInput = popup.locator("#master-password");125    await expect(masterInput).toBeVisible();126    await ctx.close();127  });128129  test("LS04 - lock screen has password input", async () => {130    const { popup } = await setupVault(ctx);131    await lockVault(popup);132    await expect(popup.locator("#master-password")).toBeVisible();133    await ctx.close();134  });135136  test("LS05 - lock screen has unlock button", async () => {137    const { popup } = await setupVault(ctx);138    await lockVault(popup);139    await expect(popup.getByRole("button", { name: /unlock/i })).toBeVisible();140    await ctx.close();141  });142143  test("LS06 - lock screen has peach branding", async () => {144    const { popup } = await setupVault(ctx);145    await lockVault(popup);146    const logo = popup.locator(".peach-logo, [class*='logo']").first();147    const hasLogo = await logo.isVisible({ timeout: 2000 }).catch(() => false);148    expect(hasLogo).toBeTruthy();149    await ctx.close();150  });151152  test("LS07 - incorrect password shows error", async () => {153    const { popup } = await setupVault(ctx);154    await lockVault(popup);155    await popup.locator("#master-password").fill("wrongpassword");156    await popup.getByRole("button", { name: /unlock/i }).click();157    await popup.waitForTimeout(500);158    const error = popup.locator("text=/error|invalid|incorrect/i");159    const hasError = await error.isVisible({ timeout: 2000 }).catch(() => false);160    expect(hasError).toBeTruthy();161    await ctx.close();162  });163164  test("LS07b - recovery screen mounts after vault creation", async () => {165    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });166    await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });167    await expect(popup.locator("text=save your recovery words")).toBeVisible({ timeout: 5000 });168    await expect(popup.locator(".peach-recovery-word-item")).toHaveCount(12);169    await expect(popup.getByRole("button", { name: /i wrote them down/i })).toBeVisible({ timeout: 5000 });170171    await ctx.close();172  });173174  test("LS07c - recovery confirmation returns to the unlocked vault", async () => {175    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });176    await completeRecoveryConfirmation(popup);177178    await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 5000 });179    await ctx.close();180  });181182  test("LS07d - recovery confirmation survives popup close and reopen", async () => {183    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });184    const confirmInputs = popup.locator(".peach-recovery-confirm-input");185    const recoveryWords = popup.locator(".peach-recovery-word-text");186187    await expect(confirmInputs.first()).toBeEnabled({ timeout: 7000 });188    const words = await recoveryWords.allTextContents();189    const firstWord = words[2]?.trim();190    if (!firstWord) throw new Error("missing recovery word for close/reopen smoke test");191    await confirmInputs.first().fill(firstWord);192193    await popup.close();194195    const reopened = await loadPopup(ctx);196    await expect(reopened.popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });197    await completeRecoveryConfirmation(reopened.popup);198199    await reopened.popup.close();200    await ctx.close();201  });202203  test("LS08 - correct password unlocks vault", async () => {204    const { popup } = await setupVault(ctx);205    await lockVault(popup);206    await popup.locator("#master-password").fill(VAULT_PASSWORD);207    await popup.getByRole("button", { name: /unlock/i }).click();208    await popup.waitForTimeout(2000);209    // A freshly created vault has no entries, so the unlocked shell (lock210    // button present) is the correct proof that unlock succeeded.211    await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 5000 });212    await ctx.close();213  });214215  test("LS09 - auto-lock after timeout", async () => {216    const { popup } = await setupVault(ctx);217    await popup.waitForTimeout(6500);218    const masterInput = popup.locator("#master-password");219    await expect(masterInput).toBeVisible({ timeout: 2000 });220    await ctx.close();221  });222223  test("LS10 - lock clears clipboard", async () => {224    const { popup } = await setupVault(ctx);225    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();226    await popup.waitForTimeout(300);227    await popup.locator("text=clipboard clear").click();228    await popup.waitForTimeout(200);229    await popup.keyboard.press("Escape");230    await popup.waitForTimeout(200);231    await lockVault(popup);232    await popup.locator("#master-password").fill(VAULT_PASSWORD);233    await popup.getByRole("button", { name: /unlock/i }).click();234    await popup.waitForTimeout(2000);235    await ctx.close();236  });237238  test("LS11 - lock persists after navigation", async () => {239    const { popup, extId } = await setupVault(ctx);240    await lockVault(popup);241    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);242    await popup.waitForTimeout(500);243    await expect(popup.locator("#master-password")).toBeVisible();244    await ctx.close();245  });246247  test("LS12 - multiple lock/unlock cycles", async () => {248    const { popup } = await setupVault(ctx);249    for (let i = 0; i < 3; i++) {250      await lockVault(popup);251      await popup.locator("#master-password").fill(VAULT_PASSWORD);252      await popup.getByRole("button", { name: /unlock/i }).click();253      await popup.waitForTimeout(1500);254    }255    // A freshly created vault has no entries, so the unlocked shell (lock256    // button present) is the correct proof that unlock succeeded.257    await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 5000 });258    await ctx.close();259  });260261  test("LS13 - lock screen shows vault icon", async () => {262    const { popup } = await setupVault(ctx);263    await lockVault(popup);264    const icon = popup.locator("[class*='vault-icon'], [class*='lock-icon']").first();265    const hasIcon = await icon.isVisible({ timeout: 2000 }).catch(() => false);266    expect(hasIcon).toBeTruthy();267    await ctx.close();268  });269270  test("LS14 - empty password shows validation error", async () => {271    const { popup } = await setupVault(ctx);272    await lockVault(popup);273    await popup.locator("#master-password").fill("");274    await popup.getByRole("button", { name: /unlock/i }).click();275    await popup.waitForTimeout(500);276    const error = popup.locator("text=/required|empty|enter/i");277    const hasError = await error.isVisible({ timeout: 2000 }).catch(() => false);278    expect(hasError).toBeTruthy();279    await ctx.close();280  });281282  test("LS15 - unlock button is disabled during loading", async () => {283    const { popup } = await setupVault(ctx);284    await lockVault(popup);285    await popup.locator("#master-password").fill(VAULT_PASSWORD);286    const unlockBtn = popup.getByRole("button", { name: /unlock/i });287    await unlockBtn.click();288    await popup.waitForTimeout(300);289    await ctx.close();290  });291292  test("LS16 - session cleared on lock", async () => {293    const { popup } = await setupVault(ctx);294    await lockVault(popup);295    const sessionCleared = await popup.evaluate(() => {296      return typeof window !== "undefined";297    });298    expect(sessionCleared).toBeTruthy();299    await ctx.close();300  });301302  test("LS17 - lock screen has keyboard support", async () => {303    const { popup } = await setupVault(ctx);304    await lockVault(popup);305    await popup.locator("#master-password").focus();306    await popup.keyboard.type(VAULT_PASSWORD);307    await popup.keyboard.press("Enter");308    await popup.waitForTimeout(2000);309    // A freshly created vault has no entries, so the unlocked shell (lock310    // button present) is the correct proof that unlock succeeded.311    await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 5000 });312    await ctx.close();313  });314315  test("LS18 - biometric unlock option if available", async () => {316    const { popup } = await setupVault(ctx);317    await lockVault(popup);318    const biometricBtn = popup.locator("[aria-label*='biometric' i], [aria-label*='fingerprint' i], text=biometric");319    const hasBiometric = await biometricBtn.isVisible({ timeout: 2000 }).catch(() => false);320    if (hasBiometric) expect(true).toBeTruthy();321    else expect(hasBiometric).toBeFalsy();322    await ctx.close();323  });324325  test("LS19 - lock transitions are smooth", async () => {326    const { popup } = await setupVault(ctx);327    const start = Date.now();328    await lockVault(popup);329    const elapsed = Date.now() - start;330    expect(elapsed).toBeLessThan(500);331    await ctx.close();332  });333334  test("LS20 - unlock shows loading state", async () => {335    const { popup } = await setupVault(ctx);336    await lockVault(popup);337    await popup.locator("#master-password").fill(VAULT_PASSWORD);338    const unlockBtn = popup.getByRole("button", { name: /unlock/i });339    await unlockBtn.click();340    await popup.waitForTimeout(300);341    await ctx.close();342  });343});344345test.describe("Lock Screen - Auto-lock Configuration", () => {346  let ctx: BrowserContext;347  test.afterEach(async () => { await ctx.close(); });348  test.beforeEach(async () => { ctx = await makeContext(); });349350  test("LAC01 - auto-lock default is 1 minute", async () => {351    const { popup } = await setupVault(ctx);352    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();353    await popup.waitForTimeout(300);354    const autoLockText = popup.locator("text=/auto-lock/i");355    const hasAutoLock = await autoLockText.isVisible().catch(() => false);356    expect(hasAutoLock).toBeTruthy();357    await ctx.close();358  });359360  test("LAC02 - auto-lock options available", async () => {361    const { popup } = await setupVault(ctx);362    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();363    await popup.waitForTimeout(300);364    await popup.locator("text=auto-lock").click();365    await popup.waitForTimeout(300);366    const options = popup.locator("[role='option' i], [role='menuitem' i]");367    const count = await options.count();368    expect(count).toBeGreaterThan(1);369    await ctx.close();370  });371372  test("LAC03 - lock on close option", async () => {373    const { popup } = await setupVault(ctx);374    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();375    await popup.waitForTimeout(300);376    const lockOnClose = popup.locator("text=/lock on close/i");377    const hasLockOnClose = await lockOnClose.isVisible().catch(() => false);378    expect(hasLockOnClose).toBeTruthy();379    await ctx.close();380  });381382  test("LAC04 - immediate lock option", async () => {383    const { popup } = await setupVault(ctx);384    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();385    await popup.waitForTimeout(300);386    await popup.locator("text=auto-lock").click();387    await popup.waitForTimeout(300);388    const immediateOpt = popup.locator("text=/immediate|0 seconds|right away/i");389    const hasImmediate = await immediateOpt.isVisible().catch(() => false);390    expect(hasImmediate).toBeTruthy();391    await ctx.close();392  });393394  test("LAC05 - 5 minute auto-lock option", async () => {395    const { popup } = await setupVault(ctx);396    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();397    await popup.waitForTimeout(300);398    await popup.locator("text=auto-lock").click();399    await popup.waitForTimeout(300);400    const fiveMinOpt = popup.locator("text=/5 minutes|5 min/i");401    const hasFiveMin = await fiveMinOpt.isVisible().catch(() => false);402    expect(hasFiveMin).toBeTruthy();403    await ctx.close();404  });405406  test("LAC06 - 15 minute auto-lock option", async () => {407    const { popup } = await setupVault(ctx);408    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();409    await popup.waitForTimeout(300);410    await popup.locator("text=auto-lock").click();411    await popup.waitForTimeout(300);412    const fifteenMinOpt = popup.locator("text=/15 minutes|15 min/i");413    const hasFifteenMin = await fifteenMinOpt.isVisible().catch(() => false);414    expect(hasFifteenMin).toBeTruthy();415    await ctx.close();416  });417418  test("LAC07 - 1 hour auto-lock option", async () => {419    const { popup } = await setupVault(ctx);420    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();421    await popup.waitForTimeout(300);422    await popup.locator("text=auto-lock").click();423    await popup.waitForTimeout(300);424    const hourOpt = popup.locator("text=/1 hour|60 minutes/i");425    const hasHour = await hourOpt.isVisible().catch(() => false);426    expect(hasHour).toBeTruthy();427    await ctx.close();428  });429430  test("LAC08 - changing auto-lock persists", async () => {431    const { popup } = await setupVault(ctx);432    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();433    await popup.waitForTimeout(300);434    await popup.locator("text=auto-lock").click();435    await popup.waitForTimeout(300);436    const option = popup.locator("[role='option' i], [role='menuitem' i]").nth(1);437    await option.click();438    await popup.waitForTimeout(300);439    await popup.keyboard.press("Escape");440    await popup.waitForTimeout(200);441    await popup.locator(".peach-icon-btn[aria-label='Settings']").click();442    await popup.waitForTimeout(300);443    await popup.locator("text=auto-lock").click();444    await popup.waitForTimeout(300);445    await ctx.close();446  });447});448449test.describe("Lock Screen - Security", () => {450  let ctx: BrowserContext;451  test.afterEach(async () => { await ctx.close(); });452  test.beforeEach(async () => { ctx = await makeContext(); });453454  test("LSC01 - no sensitive data in lock screen DOM", async () => {455    const { popup } = await setupVault(ctx);456    await lockVault(popup);457    const html = await popup.content();458    expect(html).not.toContain(VAULT_PASSWORD);459    expect(html).not.toContain("argon2");460    await ctx.close();461  });462463  test("LSC02 - lock screen has CSP compliance", async () => {464    const { popup } = await setupVault(ctx);465    await lockVault(popup);466    const csp = await popup.evaluate(() => {467      const meta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');468      return meta ? meta.getAttribute("content") : null;469    });470    if (csp) expect(csp).toContain("script-src");471    await ctx.close();472  });473474  test("LSC03 - session storage cleared on lock", async () => {475    const { popup } = await setupVault(ctx);476    await lockVault(popup);477    await popup.waitForTimeout(500);478    await ctx.close();479  });480481  test("LSC04 - memory cleared on lock", async () => {482    const { popup } = await setupVault(ctx);483    await lockVault(popup);484    const cleared = await popup.evaluate(() => {485      return typeof window.sessionStorage !== "undefined";486    });487    expect(cleared).toBeTruthy();488    await ctx.close();489  });490491  test("LSC05 - brute force protection after wrong attempts", async () => {492    const { popup } = await setupVault(ctx);493    await lockVault(popup);494    for (let i = 0; i < 5; i++) {495      await popup.locator("#master-password").fill("wrongpassword");496      await popup.getByRole("button", { name: /unlock/i }).click();497      await popup.waitForTimeout(500);498    }499    await popup.waitForTimeout(1000);500    await popup.locator("#master-password").fill(VAULT_PASSWORD);501    await popup.getByRole("button", { name: /unlock/i }).click();502    await popup.waitForTimeout(2000);503    // Fresh vault has no entries; a visible unlocked shell proves the correct504    // password still works after the wrong attempts.505    const unlocked = await popup506      .locator(".peach-lock-btn[aria-label='Lock vault']")507      .isVisible({ timeout: 3000 })508      .catch(() => false);509    expect(unlocked).toBeTruthy();510    await ctx.close();511  });512513  test("LSC06 - lock requires user gesture", async () => {514    const { popup } = await setupVault(ctx);515    const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']");516    await expect(lockBtn).toBeVisible();517    await ctx.close();518  });519});