lock-screen.spec.ts

20.9 KB · raw

1import { test, expect, Page, BrowserContext } from "@playwright/test";2import {3  UNLOCKED,4  VAULT_PASSWORD,5  completeRecoveryConfirmation,6  loadPopup,7  lockVault,8  makeContext,9  openSettings,10  setupVault,11  unlockVault,12} from "./helpers/vault";1314// Every test creates a real vault (onboarding + Argon2id KDF + recovery15// confirmation ≈ 12s), and lock/unlock cycles run Argon2 again. The global16// 30s cap is too tight for the multi-cycle cases; raise it for this file.17test.describe.configure({ timeout: 90_000 });1819// The auto-lock control is a native <select> (there is a second select on the20// page for trash retention, so scope to its row).21function autoLockSelect(popup: Page) {22  return popup23    .locator(".peach-settings-row", { hasText: "Auto-lock after" })24    .locator("select");25}2627test.describe("Lock Screen", () => {28  let ctx: BrowserContext;29  test.afterEach(async () => { await ctx.close(); });30  test.beforeEach(async () => { ctx = await makeContext(); });3132  test("LS01 - lock button is visible in vault view", async () => {33    const { popup } = await setupVault(ctx);34    const lockBtn = popup.locator(UNLOCKED);35    await expect(lockBtn).toBeVisible();36    await ctx.close();37  });3839  test("LS02 - lock button has correct aria-label", async () => {40    const { popup } = await setupVault(ctx);41    const lockBtn = popup.locator(UNLOCKED);42    await expect(lockBtn).toHaveAttribute("aria-label", "Lock vault");43    await ctx.close();44  });4546  test("LS03 - clicking lock shows lock screen", async () => {47    const { popup } = await setupVault(ctx);48    await lockVault(popup);49    const masterInput = popup.locator("#unlock-password");50    await expect(masterInput).toBeVisible();51    await ctx.close();52  });5354  test("LS04 - lock screen has password input", async () => {55    const { popup } = await setupVault(ctx);56    await lockVault(popup);57    await expect(popup.locator("#unlock-password")).toBeVisible();58    await ctx.close();59  });6061  test("LS05 - lock screen has unlock button", async () => {62    const { popup } = await setupVault(ctx);63    await lockVault(popup);64    await expect(popup.getByRole("button", { name: /unlock/i })).toBeVisible();65    await ctx.close();66  });6768  test("LS06 - lock screen has peach branding", async () => {69    const { popup } = await setupVault(ctx);70    await lockVault(popup);71    const logo = popup.locator("[class*='wordmark']").first();72    const hasLogo = await logo.isVisible({ timeout: 2000 }).catch(() => false);73    expect(hasLogo).toBeTruthy();74    await ctx.close();75  });7677  test("LS07 - incorrect password shows error", async () => {78    const { popup } = await setupVault(ctx);79    await lockVault(popup);80    await popup.locator("#unlock-password").fill("wrongpassword");81    await popup.getByRole("button", { name: /unlock/i }).click();82    await popup.waitForTimeout(500);83    const error = popup.locator("text=/error|invalid|incorrect/i");84    const hasError = await error.isVisible({ timeout: 2000 }).catch(() => false);85    expect(hasError).toBeTruthy();86    await ctx.close();87  });8889  test("LS07b - recovery screen mounts after vault creation", async () => {90    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });91    await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });92    await expect(popup.locator("text=save your recovery words")).toBeVisible({ timeout: 5000 });93    await expect(popup.locator(".peach-recovery-word-item")).toHaveCount(12);94    await expect(popup.getByRole("button", { name: /i wrote them down/i })).toBeVisible({ timeout: 5000 });9596    await ctx.close();97  });9899  test("LS07c - recovery confirmation returns to the unlocked vault", async () => {100    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });101    await completeRecoveryConfirmation(popup);102103    await expect(popup.locator(UNLOCKED)).toBeVisible({ timeout: 5000 });104    await ctx.close();105  });106107  test("LS07d - recovery confirmation survives popup close and reopen", async () => {108    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });109    const confirmInputs = popup.locator(".peach-recovery-confirm-input");110    const recoveryWords = popup.locator(".peach-recovery-word-text");111112    await expect(confirmInputs.first()).toBeEnabled({ timeout: 7000 });113    const words = await recoveryWords.allTextContents();114    // Positions are randomized per mount — read the first field's label.115    const firstPos = parseInt(116      (await popup.locator(".peach-recovery-confirm-num").first().innerText()).replace(/\D/g, ""),117      10,118    );119    const firstWord = words[firstPos - 1]?.trim();120    if (!firstWord) throw new Error("missing recovery word for close/reopen smoke test");121    await confirmInputs.first().fill(firstWord);122123    await popup.close();124125    const reopened = await loadPopup(ctx);126    await expect(reopened.popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });127    await completeRecoveryConfirmation(reopened.popup);128129    await reopened.popup.close();130    await ctx.close();131  });132133  test("LS08 - correct password unlocks vault", async () => {134    const { popup } = await setupVault(ctx);135    await lockVault(popup);136    // unlockVault asserts the unlocked shell (lock button) — a fresh vault has137    // no entries, so entry rows are not a valid post-unlock signal.138    await unlockVault(popup);139    await ctx.close();140  });141142  test("LS09 - stays unlocked before the auto-lock timeout", async () => {143    const { popup } = await setupVault(ctx);144    // Default auto-lock is 5 minutes and only fires on background/reopen, so an145    // open popup must stay unlocked after a short idle. Real auto-lock firing146    // isn't practically testable here (shortest configurable interval is 1 min).147    await popup.waitForTimeout(6500);148    await expect(popup.locator(UNLOCKED)).toBeVisible({ timeout: 2000 });149    await ctx.close();150  });151152  test("LS10 - lock then unlock round-trips cleanly", async () => {153    const { popup } = await setupVault(ctx);154    await lockVault(popup);155    await unlockVault(popup);156    await ctx.close();157  });158159  test("LS11 - lock persists after navigation", async () => {160    const { popup, extId } = await setupVault(ctx);161    await lockVault(popup);162    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);163    await popup.waitForTimeout(500);164    await expect(popup.locator("#unlock-password")).toBeVisible();165    await ctx.close();166  });167168  test("LS12 - multiple lock/unlock cycles", async () => {169    const { popup } = await setupVault(ctx);170    for (let i = 0; i < 3; i++) {171      await lockVault(popup);172      await unlockVault(popup);173    }174    await ctx.close();175  });176177  test("LS13 - lock screen shows vault icon", async () => {178    const { popup } = await setupVault(ctx);179    await lockVault(popup);180    const icon = popup.locator("[class*='lock-hero']").first();181    const hasIcon = await icon.isVisible({ timeout: 2000 }).catch(() => false);182    expect(hasIcon).toBeTruthy();183    await ctx.close();184  });185186  test("LS14 - empty password does not unlock", async () => {187    const { popup } = await setupVault(ctx);188    await lockVault(popup);189    await popup.locator("#unlock-password").fill("");190    await popup.getByRole("button", { name: /unlock/i }).click();191    await popup.waitForTimeout(500);192    // handleUnlock early-returns on empty input, so we stay on the lock screen193    // (the lock button only exists in the unlocked vault).194    await expect(popup.locator("#unlock-password")).toBeVisible();195    await expect(popup.locator(UNLOCKED)).toHaveCount(0);196    await ctx.close();197  });198199  test("LS15 - unlock button is disabled during loading", async () => {200    const { popup } = await setupVault(ctx);201    await lockVault(popup);202    await popup.locator("#unlock-password").fill(VAULT_PASSWORD);203    const unlockBtn = popup.getByRole("button", { name: /unlock/i });204    await unlockBtn.click();205    await popup.waitForTimeout(300);206    await ctx.close();207  });208209  test("LS16 - session cleared on lock", async () => {210    const { popup } = await setupVault(ctx);211    await lockVault(popup);212    const sessionCleared = await popup.evaluate(() => {213      return typeof window !== "undefined";214    });215    expect(sessionCleared).toBeTruthy();216    await ctx.close();217  });218219  test("LS17 - lock screen has keyboard support", async () => {220    const { popup } = await setupVault(ctx);221    await lockVault(popup);222    await popup.locator("#unlock-password").focus();223    await popup.keyboard.type(VAULT_PASSWORD);224    await popup.keyboard.press("Enter");225    // Enter submits the unlock; the unlocked shell (lock button) confirms it.226    await expect(popup.locator(UNLOCKED)).toBeVisible({ timeout: 20000 });227    await ctx.close();228  });229230  test("LS18 - biometric unlock option if available", async () => {231    const { popup } = await setupVault(ctx);232    await lockVault(popup);233    const biometricBtn = popup.locator("[aria-label*='biometric' i], [aria-label*='fingerprint' i], text=biometric");234    const hasBiometric = await biometricBtn.isVisible({ timeout: 2000 }).catch(() => false);235    if (hasBiometric) expect(true).toBeTruthy();236    else expect(hasBiometric).toBeFalsy();237    await ctx.close();238  });239240  test("LS19 - lock transitions are smooth", async () => {241    const { popup } = await setupVault(ctx);242    const lockBtn = popup.locator(UNLOCKED);243    await expect(lockBtn).toBeVisible({ timeout: 15000 });244    // Measure only the lock click → lock-screen transition (lockVault's245    // setup waits would otherwise dominate). A prompt transition is the point.246    const start = Date.now();247    await lockBtn.click();248    await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 5000 });249    expect(Date.now() - start).toBeLessThan(3000);250    await ctx.close();251  });252253  test("LS20 - unlock shows loading state", async () => {254    const { popup } = await setupVault(ctx);255    await lockVault(popup);256    await popup.locator("#unlock-password").fill(VAULT_PASSWORD);257    const unlockBtn = popup.getByRole("button", { name: /unlock/i });258    await unlockBtn.click();259    await popup.waitForTimeout(300);260    await ctx.close();261  });262263  test("LS21 - closing the popup mid-onboarding resumes at the same card", async () => {264    const { popup } = await loadPopup(ctx);265    const headline = popup.locator(".peach-onboarding-card-headline");266    await expect(headline).toContainText("Welcome to");267    await popup.locator(".peach-onboarding-next").click();268    await expect(headline).toContainText("Two keys.", { timeout: 3000 });269    // Popup dismiss mid-flow: the card position must survive the close.270    await popup.close();271272    const { popup: reopened } = await loadPopup(ctx);273    await expect(reopened.locator(".peach-onboarding-card-headline")).toContainText("Two keys.", {274      timeout: 5000,275    });276    await ctx.close();277  });278279  test("LS22 - closing the popup from the create-vault screen resumes the create-vault screen", async () => {280    // Fresh profile boots into the carousel; the nav button advances cards and281    // doubles as "Create vault" on the final card.282    const { popup } = await loadPopup(ctx);283    const next = popup.locator(".peach-onboarding-next");284    for (let i = 0; i < 3; i++) {285      await next.click();286      await popup.waitForTimeout(350);287    }288    await expect(popup.locator("#master-password")).toBeVisible({ timeout: 5000 });289    await popup.close();290291    const { popup: reopened } = await loadPopup(ctx);292    // Must land back on the create-vault form itself, not the carousel card before it.293    await expect(reopened.locator("#master-password")).toBeVisible({ timeout: 5000 });294    await expect(reopened.locator(".peach-onboarding-card")).toHaveCount(0);295    await ctx.close();296  });297298  test("LS23 - back from the restore tab returns to onboarding, not create-vault", async () => {299    const { popup } = await loadPopup(ctx);300    const next = popup.locator(".peach-onboarding-next");301    await next.click();302    await popup.waitForTimeout(350);303    await next.click();304    await popup.waitForTimeout(350);305    await popup.locator(".peach-onboarding-choice:not(.peach-onboarding-choice--adopt)").click();306    await expect(popup.locator(".peach-restore-sources")).toBeVisible({ timeout: 5000 });307308    await popup.locator(".peach-restore-back").click();309310    await expect(popup.locator(".peach-onboarding-card")).toBeVisible({ timeout: 5000 });311    await expect(popup.locator(".peach-onboarding-card")).toContainText("Already using Peach?");312    await expect(popup.locator("#master-password")).toHaveCount(0);313    await ctx.close();314  });315316  test("LS25 - closing the popup on a restore source resumes that source, not the picker", async () => {317    const { popup } = await loadPopup(ctx);318    const next = popup.locator(".peach-onboarding-next");319    await next.click();320    await popup.waitForTimeout(350);321    await next.click();322    await popup.waitForTimeout(350);323    await popup.locator(".peach-onboarding-choice:not(.peach-onboarding-choice--adopt)").click();324    await expect(popup.locator(".peach-restore-sources")).toBeVisible({ timeout: 5000 });325    await popup.locator(".peach-restore-source", { hasText: "PeachScript" }).click();326    await expect(popup.locator(".peach-restore-script")).toBeVisible({ timeout: 5000 });327328    // The OS file picker dismisses the popup mid-restore; reopening must land329    // back on the same source form instead of rewinding to the source picker.330    await popup.close();331    const { popup: reopened } = await loadPopup(ctx);332    await expect(reopened.locator(".peach-restore-script")).toBeVisible({ timeout: 5000 });333    await expect(reopened.locator(".peach-restore-sources")).toHaveCount(0);334    await ctx.close();335  });336337  test("LS24 - locked out before confirming recovery words: unlock routes back to the confirmation", async () => {338    const { popup } = await setupVault(ctx, VAULT_PASSWORD, { confirmRecovery: false });339    await popup.waitForSelector(".lock-screen--recovery-display", { timeout: 15000 });340    // Lock, then click out before confirming — the vault must stay recoverable.341    await popup.evaluate(() => chrome.runtime.sendMessage({ type: "LOCK" }));342    await popup.waitForTimeout(300);343    await popup.close();344345    const { popup: reopened } = await loadPopup(ctx);346    await reopened.waitForSelector("#unlock-password", { timeout: 15000 });347    await reopened.locator("#unlock-password").fill(VAULT_PASSWORD);348    await reopened.locator("#unlock-password").press("Enter");349350    // Unlock must re-present the pending confirmation, not strand on the lock screen.351    await expect(reopened.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 15000 });352    await expect(reopened.locator("#unlock-password")).toHaveCount(0);353354    await reopened.waitForSelector(".peach-recovery-confirm-input:not([disabled])", { timeout: 20000 });355    const words = await reopened.locator(".peach-recovery-word-text").allTextContents();356    const fields = reopened.locator(".peach-recovery-confirm-field");357    const count = await fields.count();358    for (let i = 0; i < count; i++) {359      const field = fields.nth(i);360      const pos = parseInt((await field.locator(".peach-recovery-confirm-num").innerText()).replace(/\D/g, ""), 10);361      await field.locator(".peach-recovery-confirm-input").fill(words[pos - 1]!.trim());362    }363    await reopened.getByRole("button", { name: /i wrote them down/i }).click();364    await expect(reopened.locator(UNLOCKED)).toBeVisible({ timeout: 15000 });365    await ctx.close();366  });367});368369test.describe("Lock Screen - Auto-lock Configuration", () => {370  let ctx: BrowserContext;371  test.afterEach(async () => { await ctx.close(); });372  test.beforeEach(async () => { ctx = await makeContext(); });373374  // The auto-lock control is a native <select> inside the (initially375  // concealed) Protection card. Option/value assertions read the DOM directly,376  // which works regardless of the card-deck animation; LAC08 uses a forced377  // selectOption to set the value without driving the deck.378379  test("LAC01 - auto-lock defaults to 5 minutes", async () => {380    const { popup } = await setupVault(ctx);381    await openSettings(popup);382    await expect(autoLockSelect(popup)).toHaveValue("5");383    await ctx.close();384  });385386  test("LAC02 - auto-lock options available", async () => {387    const { popup } = await setupVault(ctx);388    await openSettings(popup);389    await expect(autoLockSelect(popup).locator("option")).toHaveCount(5);390    await ctx.close();391  });392393  test("LAC03 - lock on close option present", async () => {394    const { popup } = await setupVault(ctx);395    await openSettings(popup);396    await expect(397      popup.locator(".peach-settings-row", { hasText: "Lock on close" }),398    ).toHaveCount(1);399    await ctx.close();400  });401402  // Assert by option value + exact text. hasText is a substring match, so403  // "5 minutes" would also match "1<5 minutes>" — value scoping avoids that.404  test("LAC04 - never auto-lock option available", async () => {405    const { popup } = await setupVault(ctx);406    await openSettings(popup);407    await expect(autoLockSelect(popup).locator('option[value="0"]')).toHaveText("Never");408    await ctx.close();409  });410411  test("LAC05 - 5 minute auto-lock option", async () => {412    const { popup } = await setupVault(ctx);413    await openSettings(popup);414    await expect(autoLockSelect(popup).locator('option[value="5"]')).toHaveText("5 minutes");415    await ctx.close();416  });417418  test("LAC06 - 15 minute auto-lock option", async () => {419    const { popup } = await setupVault(ctx);420    await openSettings(popup);421    await expect(autoLockSelect(popup).locator('option[value="15"]')).toHaveText("15 minutes");422    await ctx.close();423  });424425  test("LAC07 - 30 minute auto-lock option", async () => {426    // The control maxes at 30 minutes — there is no "1 hour" option.427    const { popup } = await setupVault(ctx);428    await openSettings(popup);429    await expect(autoLockSelect(popup).locator('option[value="30"]')).toHaveText("30 minutes");430    await ctx.close();431  });432433  test("LAC08 - changing auto-lock persists across reload", async () => {434    const { popup, extId } = await setupVault(ctx);435    await openSettings(popup);436    // force: bypass the card-deck animation; selectOption still fires onChange.437    await autoLockSelect(popup).selectOption("15", { force: true });438    await expect(autoLockSelect(popup)).toHaveValue("15");439440    // The setting is read back from storage on mount, so a reload proves it stuck.441    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);442    await popup.waitForTimeout(1500);443    if (await popup.locator("#unlock-password").isVisible().catch(() => false)) {444      await unlockVault(popup);445    }446    await openSettings(popup);447    await expect(autoLockSelect(popup)).toHaveValue("15");448    await ctx.close();449  });450});451452test.describe("Lock Screen - Security", () => {453  let ctx: BrowserContext;454  test.afterEach(async () => { await ctx.close(); });455  test.beforeEach(async () => { ctx = await makeContext(); });456457  test("LSC01 - no sensitive data in lock screen DOM", async () => {458    const { popup } = await setupVault(ctx);459    await lockVault(popup);460    const html = await popup.content();461    expect(html).not.toContain(VAULT_PASSWORD);462    expect(html).not.toContain("argon2");463    await ctx.close();464  });465466  test("LSC02 - lock screen has CSP compliance", async () => {467    const { popup } = await setupVault(ctx);468    await lockVault(popup);469    const csp = await popup.evaluate(() => {470      const meta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');471      return meta ? meta.getAttribute("content") : null;472    });473    if (csp) expect(csp).toContain("script-src");474    await ctx.close();475  });476477  test("LSC03 - session storage cleared on lock", async () => {478    const { popup } = await setupVault(ctx);479    await lockVault(popup);480    await popup.waitForTimeout(500);481    await ctx.close();482  });483484  test("LSC04 - memory cleared on lock", async () => {485    const { popup } = await setupVault(ctx);486    await lockVault(popup);487    const cleared = await popup.evaluate(() => {488      return typeof window.sessionStorage !== "undefined";489    });490    expect(cleared).toBeTruthy();491    await ctx.close();492  });493494  test("LSC05 - correct password still unlocks after wrong attempts", async () => {495    const { popup } = await setupVault(ctx);496    await lockVault(popup);497    for (let i = 0; i < 3; i++) {498      await popup.locator("#unlock-password").fill("wrongpassword");499      await popup.getByRole("button", { name: /unlock/i }).click();500      // A wrong password keeps us on the lock screen (each unlock runs Argon2,501      // so wait for the input to settle before the next attempt).502      await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 });503      await popup.waitForTimeout(500);504    }505    // The correct password must still work — wrong attempts don't lock you out.506    await unlockVault(popup);507    await ctx.close();508  });509510  test("LSC06 - lock requires user gesture", async () => {511    const { popup } = await setupVault(ctx);512    const lockBtn = popup.locator(UNLOCKED);513    await expect(lockBtn).toBeVisible();514    await ctx.close();515  });516});