lock-screen.spec.ts

22.7 KB · raw

1import { test, expect, type Page, type BrowserContext } from "@playwright/test";2import { fileURLToPath } from "node:url";34const PEACH_FILE = fileURLToPath(new URL("./fixtures/synthetic-backup.peach", import.meta.url));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("LS19 - lock transitions are smooth", async () => {234    const { popup } = await setupVault(ctx);235    const lockBtn = popup.locator(UNLOCKED);236    await expect(lockBtn).toBeVisible({ timeout: 15000 });237    // Measure only the lock click → lock-screen transition (lockVault's238    // setup waits would otherwise dominate). A prompt transition is the point.239    const start = Date.now();240    await lockBtn.click();241    await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 5000 });242    expect(Date.now() - start).toBeLessThan(3000);243    await ctx.close();244  });245246  test("LS20 - unlock shows loading state", async () => {247    const { popup } = await setupVault(ctx);248    await lockVault(popup);249    await popup.locator("#unlock-password").fill(VAULT_PASSWORD);250    const unlockBtn = popup.getByRole("button", { name: /unlock/i });251    await unlockBtn.click();252    await popup.waitForTimeout(300);253    await ctx.close();254  });255256  test("LS21 - closing the popup mid-onboarding resumes at the same card", async () => {257    const { popup } = await loadPopup(ctx);258    const headline = popup.locator(".peach-onboarding-card-headline");259    await expect(headline).toContainText("Welcome to");260    await popup.locator(".peach-onboarding-next").click();261    await expect(headline).toContainText("Two keys.", { timeout: 3000 });262    // Popup dismiss mid-flow: the card position must survive the close.263    await popup.close();264265    const { popup: reopened } = await loadPopup(ctx);266    await expect(reopened.locator(".peach-onboarding-card-headline")).toContainText("Two keys.", {267      timeout: 5000,268    });269    await ctx.close();270  });271272  test("LS22 - closing the popup from the create-vault screen resumes the create-vault screen", async () => {273    // Fresh profile boots into the carousel; the nav button advances cards and274    // doubles as "Create vault" on the final card.275    const { popup } = await loadPopup(ctx);276    const next = popup.locator(".peach-onboarding-next");277    for (let i = 0; i < 3; i++) {278      await next.click();279      await popup.waitForTimeout(350);280    }281    await expect(popup.locator("#master-password")).toBeVisible({ timeout: 5000 });282    await popup.close();283284    const { popup: reopened } = await loadPopup(ctx);285    // Must land back on the create-vault form itself, not the carousel card before it.286    await expect(reopened.locator("#master-password")).toBeVisible({ timeout: 5000 });287    await expect(reopened.locator(".peach-onboarding-card")).toHaveCount(0);288    await ctx.close();289  });290291  test("LS23 - back from the restore tab returns to onboarding, not create-vault", async () => {292    const { popup } = await loadPopup(ctx);293    const next = popup.locator(".peach-onboarding-next");294    await next.click();295    await popup.waitForTimeout(350);296    await next.click();297    await popup.waitForTimeout(350);298    await popup.locator(".peach-onboarding-choice:not(.peach-onboarding-choice--adopt)").click();299    await expect(popup.locator(".peach-restore-sources")).toBeVisible({ timeout: 5000 });300301    await popup.locator(".peach-restore-back").click();302303    await expect(popup.locator(".peach-onboarding-card")).toBeVisible({ timeout: 5000 });304    await expect(popup.locator(".peach-onboarding-card")).toContainText("Already using Peach?");305    await expect(popup.locator("#master-password")).toHaveCount(0);306    await ctx.close();307  });308309  test("LS25 - closing the popup on a restore source resumes that source, not the picker", async () => {310    const { popup } = await loadPopup(ctx);311    const next = popup.locator(".peach-onboarding-next");312    await next.click();313    await popup.waitForTimeout(350);314    await next.click();315    await popup.waitForTimeout(350);316    await popup.locator(".peach-onboarding-choice:not(.peach-onboarding-choice--adopt)").click();317    await expect(popup.locator(".peach-restore-sources")).toBeVisible({ timeout: 5000 });318    await popup.locator(".peach-restore-source", { hasText: "PeachScript" }).click();319    await expect(popup.locator(".peach-restore-script")).toBeVisible({ timeout: 5000 });320321    // The OS file picker dismisses the popup mid-restore; reopening must land322    // back on the same source form instead of rewinding to the source picker.323    await popup.close();324    const { popup: reopened } = await loadPopup(ctx);325    await expect(reopened.locator(".peach-restore-script")).toBeVisible({ timeout: 5000 });326    await expect(reopened.locator(".peach-restore-sources")).toHaveCount(0);327    await ctx.close();328  });329330  test("LS26 - the .peach picker detaches to a window that receives the file", async () => {331    // The action popup dismisses when the OS file dialog steals focus, so the332    // picker opens a standalone window; the chosen synthetic file must land there.333    const { popup } = await loadPopup(ctx);334    const next = popup.locator(".peach-onboarding-next");335    await next.click();336    await popup.waitForTimeout(350);337    await next.click();338    await popup.waitForTimeout(350);339    await popup.locator(".peach-onboarding-choice:not(.peach-onboarding-choice--adopt)").click();340    await expect(popup.locator(".peach-restore-sources")).toBeVisible({ timeout: 5000 });341    await popup.locator(".peach-restore-source", { hasText: "PeachScript" }).click();342    await expect(popup.locator(".peach-restore-script")).toBeVisible({ timeout: 5000 });343344    const detachedPromise = ctx.waitForEvent("page");345    await popup.locator(".peach-restore-file-btn").click();346    const detached = await detachedPromise;347    // The deep link best-effort auto-opens the OS picker ~50ms after mount;348    // a real click on the button always works too. Serve whichever chooser349    // fires first with the real backup.350    let served = false;351    detached.on("filechooser", (chooser) => {352      if (!served) { served = true; void chooser.setFiles(PEACH_FILE); }353      else void chooser.setFiles([]);354    });355    await detached.waitForSelector(".peach-restore-script", { timeout: 15000 });356    expect(detached.url()).toContain("restore=peachscript");357    // autoPick (best-effort) opens the picker ~50ms after mount; if the358    // platform blocks it, click the button with a real gesture instead.359    try {360      await expect(detached.locator(".peach-rune-canvas")).toBeVisible({ timeout: 3000 });361    } catch {362      try { await detached.locator(".peach-restore-file-btn").click({ timeout: 5000 }); } catch { /* autoPick raced */ }363    }364    await expect(detached.locator(".peach-rune-canvas")).toBeVisible({ timeout: 15000 });365    await detached.locator("#restore-backup-pass").fill("backup-passphrase-probe");366    await expect(detached.locator(".peach-restore-submit")).toBeEnabled({ timeout: 15000 });367    await ctx.close();368  });369370  test("LS24 - locked out before confirming recovery words: unlock routes back to the confirmation", async () => {371    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });372    await popup.waitForSelector(".lock-screen--recovery-display", { timeout: 15000 });373    // Lock, then click out before confirming — the vault must stay recoverable.374    await popup.evaluate(() => chrome.runtime.sendMessage({ type: "LOCK" }));375    await popup.waitForTimeout(300);376    await popup.close();377378    const { popup: reopened } = await loadPopup(ctx);379    await reopened.waitForSelector("#unlock-password", { timeout: 15000 });380    await reopened.locator("#unlock-password").fill(VAULT_PASSWORD);381    await reopened.locator("#unlock-password").press("Enter");382383    // Unlock must re-present the pending confirmation, not strand on the lock screen.384    await expect(reopened.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 15000 });385    await expect(reopened.locator("#unlock-password")).toHaveCount(0);386387    await reopened.waitForSelector(".peach-recovery-confirm-input:not([disabled])", { timeout: 20000 });388    const words = await reopened.locator(".peach-recovery-word-text").allTextContents();389    const fields = reopened.locator(".peach-recovery-confirm-field");390    const count = await fields.count();391    for (let i = 0; i < count; i++) {392      const field = fields.nth(i);393      const pos = parseInt((await field.locator(".peach-recovery-confirm-num").innerText()).replace(/\D/g, ""), 10);394      await field.locator(".peach-recovery-confirm-input").fill(words[pos - 1]!.trim());395    }396    await reopened.getByRole("button", { name: /i wrote them down/i }).click();397    await expect(reopened.locator(UNLOCKED)).toBeVisible({ timeout: 15000 });398    await ctx.close();399  });400});401402test.describe("Lock Screen - Auto-lock Configuration", () => {403  let ctx: BrowserContext;404  test.afterEach(async () => { await ctx.close(); });405  test.beforeEach(async () => { ctx = await makeContext(); });406407  // The auto-lock control is a native <select> inside the (initially408  // concealed) Protection card. Option/value assertions read the DOM directly,409  // which works regardless of the card-deck animation; LAC08 uses a forced410  // selectOption to set the value without driving the deck.411412  test("LAC01 - auto-lock defaults to 5 minutes", async () => {413    const { popup } = await setupVault(ctx);414    await openSettings(popup);415    await expect(autoLockSelect(popup)).toHaveValue("5");416    await ctx.close();417  });418419  test("LAC02 - auto-lock options available", async () => {420    const { popup } = await setupVault(ctx);421    await openSettings(popup);422    await expect(autoLockSelect(popup).locator("option")).toHaveCount(5);423    await ctx.close();424  });425426  test("LAC03 - lock on close option present", async () => {427    const { popup } = await setupVault(ctx);428    await openSettings(popup);429    await expect(430      popup.locator(".peach-settings-row", { hasText: "Lock on close" }),431    ).toHaveCount(1);432    await ctx.close();433  });434435  // Assert by option value + exact text. hasText is a substring match, so436  // "5 minutes" would also match "1<5 minutes>" — value scoping avoids that.437  test("LAC04 - never auto-lock option available", async () => {438    const { popup } = await setupVault(ctx);439    await openSettings(popup);440    await expect(autoLockSelect(popup).locator('option[value="0"]')).toHaveText("Never");441    await ctx.close();442  });443444  test("LAC05 - 5 minute auto-lock option", async () => {445    const { popup } = await setupVault(ctx);446    await openSettings(popup);447    await expect(autoLockSelect(popup).locator('option[value="5"]')).toHaveText("5 minutes");448    await ctx.close();449  });450451  test("LAC06 - 15 minute auto-lock option", async () => {452    const { popup } = await setupVault(ctx);453    await openSettings(popup);454    await expect(autoLockSelect(popup).locator('option[value="15"]')).toHaveText("15 minutes");455    await ctx.close();456  });457458  test("LAC07 - 30 minute auto-lock option", async () => {459    // The control maxes at 30 minutes — there is no "1 hour" option.460    const { popup } = await setupVault(ctx);461    await openSettings(popup);462    await expect(autoLockSelect(popup).locator('option[value="30"]')).toHaveText("30 minutes");463    await ctx.close();464  });465466  test("LAC08 - changing auto-lock persists across reload", async () => {467    const { popup, extId } = await setupVault(ctx);468    await openSettings(popup);469    // force: bypass the card-deck animation; selectOption still fires onChange.470    await autoLockSelect(popup).selectOption("15", { force: true });471    await expect(autoLockSelect(popup)).toHaveValue("15");472473    // The setting is read back from storage on mount, so a reload proves it stuck.474    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);475    await popup.waitForTimeout(1500);476    if (await popup.locator("#unlock-password").isVisible().catch(() => false)) {477      await unlockVault(popup);478    }479    await openSettings(popup);480    await expect(autoLockSelect(popup)).toHaveValue("15");481    await ctx.close();482  });483});484485test.describe("Lock Screen - Security", () => {486  let ctx: BrowserContext;487  test.afterEach(async () => { await ctx.close(); });488  test.beforeEach(async () => { ctx = await makeContext(); });489490  test("LSC01 - no sensitive data in lock screen DOM", async () => {491    const { popup } = await setupVault(ctx);492    await lockVault(popup);493    const html = await popup.content();494    expect(html).not.toContain(VAULT_PASSWORD);495    expect(html).not.toContain("argon2");496    await ctx.close();497  });498499  test("LSC02 - lock screen has CSP compliance", async () => {500    const { popup } = await setupVault(ctx);501    await lockVault(popup);502    const csp = await popup.evaluate(() => {503      const meta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');504      return meta ? meta.getAttribute("content") : null;505    });506    if (csp) expect(csp).toContain("script-src");507    await ctx.close();508  });509510  test("LSC03 - session storage cleared on lock", async () => {511    const { popup } = await setupVault(ctx);512    await lockVault(popup);513    await popup.waitForTimeout(500);514    await ctx.close();515  });516517  test("LSC04 - memory cleared on lock", async () => {518    const { popup } = await setupVault(ctx);519    await lockVault(popup);520    const cleared = await popup.evaluate(() => {521      return typeof window.sessionStorage !== "undefined";522    });523    expect(cleared).toBeTruthy();524    await ctx.close();525  });526527  test("LSC05 - correct password still unlocks after wrong attempts", async () => {528    const { popup } = await setupVault(ctx);529    await lockVault(popup);530    for (let i = 0; i < 3; i++) {531      await popup.locator("#unlock-password").fill("wrongpassword");532      await popup.getByRole("button", { name: /unlock/i }).click();533      // A wrong password keeps us on the lock screen (each unlock runs Argon2,534      // so wait for the input to settle before the next attempt).535      await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 });536      await popup.waitForTimeout(500);537    }538    // The correct password must still work — wrong attempts don't lock you out.539    await unlockVault(popup);540    await ctx.close();541  });542543  test("LSC06 - lock requires user gesture", async () => {544    const { popup } = await setupVault(ctx);545    const lockBtn = popup.locator(UNLOCKED);546    await expect(lockBtn).toBeVisible();547    await ctx.close();548  });549});