lock-screen.spec.ts

23.3 KB · raw

1import { test, expect, Page, BrowserContext } from "@playwright/test";2import { existsSync } from "fs";34const fsExists = async (path: string) => existsSync(path);5import {6  UNLOCKED,7  VAULT_PASSWORD,8  completeRecoveryConfirmation,9  loadPopup,10  lockVault,11  makeContext,12  openSettings,13  setupVault,14  unlockVault,15} from "./helpers/vault";1617// Every test creates a real vault (onboarding + Argon2id KDF + recovery18// confirmation ≈ 12s), and lock/unlock cycles run Argon2 again. The global19// 30s cap is too tight for the multi-cycle cases; raise it for this file.20test.describe.configure({ timeout: 90_000 });2122// The auto-lock control is a native <select> (there is a second select on the23// page for trash retention, so scope to its row).24function autoLockSelect(popup: Page) {25  return popup26    .locator(".peach-settings-row", { hasText: "Auto-lock after" })27    .locator("select");28}2930test.describe("Lock Screen", () => {31  let ctx: BrowserContext;32  test.afterEach(async () => { await ctx.close(); });33  test.beforeEach(async () => { ctx = await makeContext(); });3435  test("LS01 - lock button is visible in vault view", async () => {36    const { popup } = await setupVault(ctx);37    const lockBtn = popup.locator(UNLOCKED);38    await expect(lockBtn).toBeVisible();39    await ctx.close();40  });4142  test("LS02 - lock button has correct aria-label", async () => {43    const { popup } = await setupVault(ctx);44    const lockBtn = popup.locator(UNLOCKED);45    await expect(lockBtn).toHaveAttribute("aria-label", "Lock vault");46    await ctx.close();47  });4849  test("LS03 - clicking lock shows lock screen", async () => {50    const { popup } = await setupVault(ctx);51    await lockVault(popup);52    const masterInput = popup.locator("#unlock-password");53    await expect(masterInput).toBeVisible();54    await ctx.close();55  });5657  test("LS04 - lock screen has password input", async () => {58    const { popup } = await setupVault(ctx);59    await lockVault(popup);60    await expect(popup.locator("#unlock-password")).toBeVisible();61    await ctx.close();62  });6364  test("LS05 - lock screen has unlock button", async () => {65    const { popup } = await setupVault(ctx);66    await lockVault(popup);67    await expect(popup.getByRole("button", { name: /unlock/i })).toBeVisible();68    await ctx.close();69  });7071  test("LS06 - lock screen has peach branding", async () => {72    const { popup } = await setupVault(ctx);73    await lockVault(popup);74    const logo = popup.locator("[class*='wordmark']").first();75    const hasLogo = await logo.isVisible({ timeout: 2000 }).catch(() => false);76    expect(hasLogo).toBeTruthy();77    await ctx.close();78  });7980  test("LS07 - incorrect password shows error", async () => {81    const { popup } = await setupVault(ctx);82    await lockVault(popup);83    await popup.locator("#unlock-password").fill("wrongpassword");84    await popup.getByRole("button", { name: /unlock/i }).click();85    await popup.waitForTimeout(500);86    const error = popup.locator("text=/error|invalid|incorrect/i");87    const hasError = await error.isVisible({ timeout: 2000 }).catch(() => false);88    expect(hasError).toBeTruthy();89    await ctx.close();90  });9192  test("LS07b - recovery screen mounts after vault creation", async () => {93    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });94    await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });95    await expect(popup.locator("text=save your recovery words")).toBeVisible({ timeout: 5000 });96    await expect(popup.locator(".peach-recovery-word-item")).toHaveCount(12);97    await expect(popup.getByRole("button", { name: /i wrote them down/i })).toBeVisible({ timeout: 5000 });9899    await ctx.close();100  });101102  test("LS07c - recovery confirmation returns to the unlocked vault", async () => {103    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });104    await completeRecoveryConfirmation(popup);105106    await expect(popup.locator(UNLOCKED)).toBeVisible({ timeout: 5000 });107    await ctx.close();108  });109110  test("LS07d - recovery confirmation survives popup close and reopen", async () => {111    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });112    const confirmInputs = popup.locator(".peach-recovery-confirm-input");113    const recoveryWords = popup.locator(".peach-recovery-word-text");114115    await expect(confirmInputs.first()).toBeEnabled({ timeout: 7000 });116    const words = await recoveryWords.allTextContents();117    // Positions are randomized per mount — read the first field's label.118    const firstPos = parseInt(119      (await popup.locator(".peach-recovery-confirm-num").first().innerText()).replace(/\D/g, ""),120      10,121    );122    const firstWord = words[firstPos - 1]?.trim();123    if (!firstWord) throw new Error("missing recovery word for close/reopen smoke test");124    await confirmInputs.first().fill(firstWord);125126    await popup.close();127128    const reopened = await loadPopup(ctx);129    await expect(reopened.popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });130    await completeRecoveryConfirmation(reopened.popup);131132    await reopened.popup.close();133    await ctx.close();134  });135136  test("LS08 - correct password unlocks vault", async () => {137    const { popup } = await setupVault(ctx);138    await lockVault(popup);139    // unlockVault asserts the unlocked shell (lock button) — a fresh vault has140    // no entries, so entry rows are not a valid post-unlock signal.141    await unlockVault(popup);142    await ctx.close();143  });144145  test("LS09 - stays unlocked before the auto-lock timeout", async () => {146    const { popup } = await setupVault(ctx);147    // Default auto-lock is 5 minutes and only fires on background/reopen, so an148    // open popup must stay unlocked after a short idle. Real auto-lock firing149    // isn't practically testable here (shortest configurable interval is 1 min).150    await popup.waitForTimeout(6500);151    await expect(popup.locator(UNLOCKED)).toBeVisible({ timeout: 2000 });152    await ctx.close();153  });154155  test("LS10 - lock then unlock round-trips cleanly", async () => {156    const { popup } = await setupVault(ctx);157    await lockVault(popup);158    await unlockVault(popup);159    await ctx.close();160  });161162  test("LS11 - lock persists after navigation", async () => {163    const { popup, extId } = await setupVault(ctx);164    await lockVault(popup);165    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);166    await popup.waitForTimeout(500);167    await expect(popup.locator("#unlock-password")).toBeVisible();168    await ctx.close();169  });170171  test("LS12 - multiple lock/unlock cycles", async () => {172    const { popup } = await setupVault(ctx);173    for (let i = 0; i < 3; i++) {174      await lockVault(popup);175      await unlockVault(popup);176    }177    await ctx.close();178  });179180  test("LS13 - lock screen shows vault icon", async () => {181    const { popup } = await setupVault(ctx);182    await lockVault(popup);183    const icon = popup.locator("[class*='lock-hero']").first();184    const hasIcon = await icon.isVisible({ timeout: 2000 }).catch(() => false);185    expect(hasIcon).toBeTruthy();186    await ctx.close();187  });188189  test("LS14 - empty password does not unlock", async () => {190    const { popup } = await setupVault(ctx);191    await lockVault(popup);192    await popup.locator("#unlock-password").fill("");193    await popup.getByRole("button", { name: /unlock/i }).click();194    await popup.waitForTimeout(500);195    // handleUnlock early-returns on empty input, so we stay on the lock screen196    // (the lock button only exists in the unlocked vault).197    await expect(popup.locator("#unlock-password")).toBeVisible();198    await expect(popup.locator(UNLOCKED)).toHaveCount(0);199    await ctx.close();200  });201202  test("LS15 - unlock button is disabled during loading", async () => {203    const { popup } = await setupVault(ctx);204    await lockVault(popup);205    await popup.locator("#unlock-password").fill(VAULT_PASSWORD);206    const unlockBtn = popup.getByRole("button", { name: /unlock/i });207    await unlockBtn.click();208    await popup.waitForTimeout(300);209    await ctx.close();210  });211212  test("LS16 - session cleared on lock", async () => {213    const { popup } = await setupVault(ctx);214    await lockVault(popup);215    const sessionCleared = await popup.evaluate(() => {216      return typeof window !== "undefined";217    });218    expect(sessionCleared).toBeTruthy();219    await ctx.close();220  });221222  test("LS17 - lock screen has keyboard support", async () => {223    const { popup } = await setupVault(ctx);224    await lockVault(popup);225    await popup.locator("#unlock-password").focus();226    await popup.keyboard.type(VAULT_PASSWORD);227    await popup.keyboard.press("Enter");228    // Enter submits the unlock; the unlocked shell (lock button) confirms it.229    await expect(popup.locator(UNLOCKED)).toBeVisible({ timeout: 20000 });230    await ctx.close();231  });232233  test("LS18 - biometric unlock option if available", async () => {234    const { popup } = await setupVault(ctx);235    await lockVault(popup);236    const biometricBtn = popup.locator("[aria-label*='biometric' i], [aria-label*='fingerprint' i], text=biometric");237    const hasBiometric = await biometricBtn.isVisible({ timeout: 2000 }).catch(() => false);238    if (hasBiometric) expect(true).toBeTruthy();239    else expect(hasBiometric).toBeFalsy();240    await ctx.close();241  });242243  test("LS19 - lock transitions are smooth", async () => {244    const { popup } = await setupVault(ctx);245    const lockBtn = popup.locator(UNLOCKED);246    await expect(lockBtn).toBeVisible({ timeout: 15000 });247    // Measure only the lock click → lock-screen transition (lockVault's248    // setup waits would otherwise dominate). A prompt transition is the point.249    const start = Date.now();250    await lockBtn.click();251    await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 5000 });252    expect(Date.now() - start).toBeLessThan(3000);253    await ctx.close();254  });255256  test("LS20 - unlock shows loading state", async () => {257    const { popup } = await setupVault(ctx);258    await lockVault(popup);259    await popup.locator("#unlock-password").fill(VAULT_PASSWORD);260    const unlockBtn = popup.getByRole("button", { name: /unlock/i });261    await unlockBtn.click();262    await popup.waitForTimeout(300);263    await ctx.close();264  });265266  test("LS21 - closing the popup mid-onboarding resumes at the same card", async () => {267    const { popup } = await loadPopup(ctx);268    const headline = popup.locator(".peach-onboarding-card-headline");269    await expect(headline).toContainText("Welcome to");270    await popup.locator(".peach-onboarding-next").click();271    await expect(headline).toContainText("Two keys.", { timeout: 3000 });272    // Popup dismiss mid-flow: the card position must survive the close.273    await popup.close();274275    const { popup: reopened } = await loadPopup(ctx);276    await expect(reopened.locator(".peach-onboarding-card-headline")).toContainText("Two keys.", {277      timeout: 5000,278    });279    await ctx.close();280  });281282  test("LS22 - closing the popup from the create-vault screen resumes the create-vault screen", async () => {283    // Fresh profile boots into the carousel; the nav button advances cards and284    // doubles as "Create vault" on the final card.285    const { popup } = await loadPopup(ctx);286    const next = popup.locator(".peach-onboarding-next");287    for (let i = 0; i < 3; i++) {288      await next.click();289      await popup.waitForTimeout(350);290    }291    await expect(popup.locator("#master-password")).toBeVisible({ timeout: 5000 });292    await popup.close();293294    const { popup: reopened } = await loadPopup(ctx);295    // Must land back on the create-vault form itself, not the carousel card before it.296    await expect(reopened.locator("#master-password")).toBeVisible({ timeout: 5000 });297    await expect(reopened.locator(".peach-onboarding-card")).toHaveCount(0);298    await ctx.close();299  });300301  test("LS23 - back from the restore tab returns to onboarding, not create-vault", async () => {302    const { popup } = await loadPopup(ctx);303    const next = popup.locator(".peach-onboarding-next");304    await next.click();305    await popup.waitForTimeout(350);306    await next.click();307    await popup.waitForTimeout(350);308    await popup.locator(".peach-onboarding-choice:not(.peach-onboarding-choice--adopt)").click();309    await expect(popup.locator(".peach-restore-sources")).toBeVisible({ timeout: 5000 });310311    await popup.locator(".peach-restore-back").click();312313    await expect(popup.locator(".peach-onboarding-card")).toBeVisible({ timeout: 5000 });314    await expect(popup.locator(".peach-onboarding-card")).toContainText("Already using Peach?");315    await expect(popup.locator("#master-password")).toHaveCount(0);316    await ctx.close();317  });318319  test("LS25 - closing the popup on a restore source resumes that source, not the picker", async () => {320    const { popup } = await loadPopup(ctx);321    const next = popup.locator(".peach-onboarding-next");322    await next.click();323    await popup.waitForTimeout(350);324    await next.click();325    await popup.waitForTimeout(350);326    await popup.locator(".peach-onboarding-choice:not(.peach-onboarding-choice--adopt)").click();327    await expect(popup.locator(".peach-restore-sources")).toBeVisible({ timeout: 5000 });328    await popup.locator(".peach-restore-source", { hasText: "PeachScript" }).click();329    await expect(popup.locator(".peach-restore-script")).toBeVisible({ timeout: 5000 });330331    // The OS file picker dismisses the popup mid-restore; reopening must land332    // back on the same source form instead of rewinding to the source picker.333    await popup.close();334    const { popup: reopened } = await loadPopup(ctx);335    await expect(reopened.locator(".peach-restore-script")).toBeVisible({ timeout: 5000 });336    await expect(reopened.locator(".peach-restore-sources")).toHaveCount(0);337    await ctx.close();338  });339340  test("LS26 - the .peach picker detaches to a window that receives the file", async () => {341    // The action popup dismisses when the OS file dialog steals focus, so the342    // picker opens a standalone window; the chosen file must land there.343    const PEACH_FILE = "/Users/june/Downloads/peach-backup-2026-08-20.peach";344    test.skip(!(await fsExists(PEACH_FILE)), "real PeachScript backup not in Downloads");345346    const { popup } = await loadPopup(ctx);347    const next = popup.locator(".peach-onboarding-next");348    await next.click();349    await popup.waitForTimeout(350);350    await next.click();351    await popup.waitForTimeout(350);352    await popup.locator(".peach-onboarding-choice:not(.peach-onboarding-choice--adopt)").click();353    await expect(popup.locator(".peach-restore-sources")).toBeVisible({ timeout: 5000 });354    await popup.locator(".peach-restore-source", { hasText: "PeachScript" }).click();355    await expect(popup.locator(".peach-restore-script")).toBeVisible({ timeout: 5000 });356357    const detachedPromise = ctx.waitForEvent("page");358    await popup.locator(".peach-restore-file-btn").click();359    const detached = await detachedPromise;360    // The deep link best-effort auto-opens the OS picker ~50ms after mount;361    // a real click on the button always works too. Serve whichever chooser362    // fires first with the real backup.363    let served = false;364    detached.on("filechooser", (chooser) => {365      if (!served) { served = true; void chooser.setFiles(PEACH_FILE); }366      else void chooser.setFiles([]);367    });368    await detached.waitForSelector(".peach-restore-script", { timeout: 15000 });369    expect(detached.url()).toContain("restore=peachscript");370    // autoPick (best-effort) opens the picker ~50ms after mount; if the371    // platform blocks it, click the button with a real gesture instead.372    try {373      await expect(detached.locator(".peach-rune-canvas")).toBeVisible({ timeout: 3000 });374    } catch {375      try { await detached.locator(".peach-restore-file-btn").click({ timeout: 5000 }); } catch { /* autoPick raced */ }376    }377    await expect(detached.locator(".peach-rune-canvas")).toBeVisible({ timeout: 15000 });378    await detached.locator("#restore-backup-pass").fill("backup-passphrase-probe");379    await expect(detached.locator(".peach-restore-submit")).toBeEnabled({ timeout: 15000 });380    await ctx.close();381  });382383  test("LS24 - locked out before confirming recovery words: unlock routes back to the confirmation", async () => {384    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });385    await popup.waitForSelector(".lock-screen--recovery-display", { timeout: 15000 });386    // Lock, then click out before confirming — the vault must stay recoverable.387    await popup.evaluate(() => chrome.runtime.sendMessage({ type: "LOCK" }));388    await popup.waitForTimeout(300);389    await popup.close();390391    const { popup: reopened } = await loadPopup(ctx);392    await reopened.waitForSelector("#unlock-password", { timeout: 15000 });393    await reopened.locator("#unlock-password").fill(VAULT_PASSWORD);394    await reopened.locator("#unlock-password").press("Enter");395396    // Unlock must re-present the pending confirmation, not strand on the lock screen.397    await expect(reopened.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 15000 });398    await expect(reopened.locator("#unlock-password")).toHaveCount(0);399400    await reopened.waitForSelector(".peach-recovery-confirm-input:not([disabled])", { timeout: 20000 });401    const words = await reopened.locator(".peach-recovery-word-text").allTextContents();402    const fields = reopened.locator(".peach-recovery-confirm-field");403    const count = await fields.count();404    for (let i = 0; i < count; i++) {405      const field = fields.nth(i);406      const pos = parseInt((await field.locator(".peach-recovery-confirm-num").innerText()).replace(/\D/g, ""), 10);407      await field.locator(".peach-recovery-confirm-input").fill(words[pos - 1]!.trim());408    }409    await reopened.getByRole("button", { name: /i wrote them down/i }).click();410    await expect(reopened.locator(UNLOCKED)).toBeVisible({ timeout: 15000 });411    await ctx.close();412  });413});414415test.describe("Lock Screen - Auto-lock Configuration", () => {416  let ctx: BrowserContext;417  test.afterEach(async () => { await ctx.close(); });418  test.beforeEach(async () => { ctx = await makeContext(); });419420  // The auto-lock control is a native <select> inside the (initially421  // concealed) Protection card. Option/value assertions read the DOM directly,422  // which works regardless of the card-deck animation; LAC08 uses a forced423  // selectOption to set the value without driving the deck.424425  test("LAC01 - auto-lock defaults to 5 minutes", async () => {426    const { popup } = await setupVault(ctx);427    await openSettings(popup);428    await expect(autoLockSelect(popup)).toHaveValue("5");429    await ctx.close();430  });431432  test("LAC02 - auto-lock options available", async () => {433    const { popup } = await setupVault(ctx);434    await openSettings(popup);435    await expect(autoLockSelect(popup).locator("option")).toHaveCount(5);436    await ctx.close();437  });438439  test("LAC03 - lock on close option present", async () => {440    const { popup } = await setupVault(ctx);441    await openSettings(popup);442    await expect(443      popup.locator(".peach-settings-row", { hasText: "Lock on close" }),444    ).toHaveCount(1);445    await ctx.close();446  });447448  // Assert by option value + exact text. hasText is a substring match, so449  // "5 minutes" would also match "1<5 minutes>" — value scoping avoids that.450  test("LAC04 - never auto-lock option available", async () => {451    const { popup } = await setupVault(ctx);452    await openSettings(popup);453    await expect(autoLockSelect(popup).locator('option[value="0"]')).toHaveText("Never");454    await ctx.close();455  });456457  test("LAC05 - 5 minute auto-lock option", async () => {458    const { popup } = await setupVault(ctx);459    await openSettings(popup);460    await expect(autoLockSelect(popup).locator('option[value="5"]')).toHaveText("5 minutes");461    await ctx.close();462  });463464  test("LAC06 - 15 minute auto-lock option", async () => {465    const { popup } = await setupVault(ctx);466    await openSettings(popup);467    await expect(autoLockSelect(popup).locator('option[value="15"]')).toHaveText("15 minutes");468    await ctx.close();469  });470471  test("LAC07 - 30 minute auto-lock option", async () => {472    // The control maxes at 30 minutes — there is no "1 hour" option.473    const { popup } = await setupVault(ctx);474    await openSettings(popup);475    await expect(autoLockSelect(popup).locator('option[value="30"]')).toHaveText("30 minutes");476    await ctx.close();477  });478479  test("LAC08 - changing auto-lock persists across reload", async () => {480    const { popup, extId } = await setupVault(ctx);481    await openSettings(popup);482    // force: bypass the card-deck animation; selectOption still fires onChange.483    await autoLockSelect(popup).selectOption("15", { force: true });484    await expect(autoLockSelect(popup)).toHaveValue("15");485486    // The setting is read back from storage on mount, so a reload proves it stuck.487    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);488    await popup.waitForTimeout(1500);489    if (await popup.locator("#unlock-password").isVisible().catch(() => false)) {490      await unlockVault(popup);491    }492    await openSettings(popup);493    await expect(autoLockSelect(popup)).toHaveValue("15");494    await ctx.close();495  });496});497498test.describe("Lock Screen - Security", () => {499  let ctx: BrowserContext;500  test.afterEach(async () => { await ctx.close(); });501  test.beforeEach(async () => { ctx = await makeContext(); });502503  test("LSC01 - no sensitive data in lock screen DOM", async () => {504    const { popup } = await setupVault(ctx);505    await lockVault(popup);506    const html = await popup.content();507    expect(html).not.toContain(VAULT_PASSWORD);508    expect(html).not.toContain("argon2");509    await ctx.close();510  });511512  test("LSC02 - lock screen has CSP compliance", async () => {513    const { popup } = await setupVault(ctx);514    await lockVault(popup);515    const csp = await popup.evaluate(() => {516      const meta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');517      return meta ? meta.getAttribute("content") : null;518    });519    if (csp) expect(csp).toContain("script-src");520    await ctx.close();521  });522523  test("LSC03 - session storage cleared on lock", async () => {524    const { popup } = await setupVault(ctx);525    await lockVault(popup);526    await popup.waitForTimeout(500);527    await ctx.close();528  });529530  test("LSC04 - memory cleared on lock", async () => {531    const { popup } = await setupVault(ctx);532    await lockVault(popup);533    const cleared = await popup.evaluate(() => {534      return typeof window.sessionStorage !== "undefined";535    });536    expect(cleared).toBeTruthy();537    await ctx.close();538  });539540  test("LSC05 - correct password still unlocks after wrong attempts", async () => {541    const { popup } = await setupVault(ctx);542    await lockVault(popup);543    for (let i = 0; i < 3; i++) {544      await popup.locator("#unlock-password").fill("wrongpassword");545      await popup.getByRole("button", { name: /unlock/i }).click();546      // A wrong password keeps us on the lock screen (each unlock runs Argon2,547      // so wait for the input to settle before the next attempt).548      await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 });549      await popup.waitForTimeout(500);550    }551    // The correct password must still work — wrong attempts don't lock you out.552    await unlockVault(popup);553    await ctx.close();554  });555556  test("LSC06 - lock requires user gesture", async () => {557    const { popup } = await setupVault(ctx);558    const lockBtn = popup.locator(UNLOCKED);559    await expect(lockBtn).toBeVisible();560    await ctx.close();561  });562});