lock-screen.spec.ts

16.1 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    const firstWord = words[2]?.trim();115    if (!firstWord) throw new Error("missing recovery word for close/reopen smoke test");116    await confirmInputs.first().fill(firstWord);117118    await popup.close();119120    const reopened = await loadPopup(ctx);121    await expect(reopened.popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 5000 });122    await completeRecoveryConfirmation(reopened.popup);123124    await reopened.popup.close();125    await ctx.close();126  });127128  test("LS08 - correct password unlocks vault", async () => {129    const { popup } = await setupVault(ctx);130    await lockVault(popup);131    // unlockVault asserts the unlocked shell (lock button) — a fresh vault has132    // no entries, so entry rows are not a valid post-unlock signal.133    await unlockVault(popup);134    await ctx.close();135  });136137  test("LS09 - stays unlocked before the auto-lock timeout", async () => {138    const { popup } = await setupVault(ctx);139    // Default auto-lock is 5 minutes and only fires on background/reopen, so an140    // open popup must stay unlocked after a short idle. Real auto-lock firing141    // isn't practically testable here (shortest configurable interval is 1 min).142    await popup.waitForTimeout(6500);143    await expect(popup.locator(UNLOCKED)).toBeVisible({ timeout: 2000 });144    await ctx.close();145  });146147  test("LS10 - lock then unlock round-trips cleanly", async () => {148    const { popup } = await setupVault(ctx);149    await lockVault(popup);150    await unlockVault(popup);151    await ctx.close();152  });153154  test("LS11 - lock persists after navigation", async () => {155    const { popup, extId } = await setupVault(ctx);156    await lockVault(popup);157    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);158    await popup.waitForTimeout(500);159    await expect(popup.locator("#unlock-password")).toBeVisible();160    await ctx.close();161  });162163  test("LS12 - multiple lock/unlock cycles", async () => {164    const { popup } = await setupVault(ctx);165    for (let i = 0; i < 3; i++) {166      await lockVault(popup);167      await unlockVault(popup);168    }169    await ctx.close();170  });171172  test("LS13 - lock screen shows vault icon", async () => {173    const { popup } = await setupVault(ctx);174    await lockVault(popup);175    const icon = popup.locator("[class*='lock-hero']").first();176    const hasIcon = await icon.isVisible({ timeout: 2000 }).catch(() => false);177    expect(hasIcon).toBeTruthy();178    await ctx.close();179  });180181  test("LS14 - empty password does not unlock", async () => {182    const { popup } = await setupVault(ctx);183    await lockVault(popup);184    await popup.locator("#unlock-password").fill("");185    await popup.getByRole("button", { name: /unlock/i }).click();186    await popup.waitForTimeout(500);187    // handleUnlock early-returns on empty input, so we stay on the lock screen188    // (the lock button only exists in the unlocked vault).189    await expect(popup.locator("#unlock-password")).toBeVisible();190    await expect(popup.locator(UNLOCKED)).toHaveCount(0);191    await ctx.close();192  });193194  test("LS15 - unlock button is disabled during loading", async () => {195    const { popup } = await setupVault(ctx);196    await lockVault(popup);197    await popup.locator("#unlock-password").fill(VAULT_PASSWORD);198    const unlockBtn = popup.getByRole("button", { name: /unlock/i });199    await unlockBtn.click();200    await popup.waitForTimeout(300);201    await ctx.close();202  });203204  test("LS16 - session cleared on lock", async () => {205    const { popup } = await setupVault(ctx);206    await lockVault(popup);207    const sessionCleared = await popup.evaluate(() => {208      return typeof window !== "undefined";209    });210    expect(sessionCleared).toBeTruthy();211    await ctx.close();212  });213214  test("LS17 - lock screen has keyboard support", async () => {215    const { popup } = await setupVault(ctx);216    await lockVault(popup);217    await popup.locator("#unlock-password").focus();218    await popup.keyboard.type(VAULT_PASSWORD);219    await popup.keyboard.press("Enter");220    // Enter submits the unlock; the unlocked shell (lock button) confirms it.221    await expect(popup.locator(UNLOCKED)).toBeVisible({ timeout: 20000 });222    await ctx.close();223  });224225  test("LS18 - biometric unlock option if available", async () => {226    const { popup } = await setupVault(ctx);227    await lockVault(popup);228    const biometricBtn = popup.locator("[aria-label*='biometric' i], [aria-label*='fingerprint' i], text=biometric");229    const hasBiometric = await biometricBtn.isVisible({ timeout: 2000 }).catch(() => false);230    if (hasBiometric) expect(true).toBeTruthy();231    else expect(hasBiometric).toBeFalsy();232    await ctx.close();233  });234235  test("LS19 - lock transitions are smooth", async () => {236    const { popup } = await setupVault(ctx);237    const lockBtn = popup.locator(UNLOCKED);238    await expect(lockBtn).toBeVisible({ timeout: 15000 });239    // Measure only the lock click → lock-screen transition (lockVault's240    // setup waits would otherwise dominate). A prompt transition is the point.241    const start = Date.now();242    await lockBtn.click();243    await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 5000 });244    expect(Date.now() - start).toBeLessThan(3000);245    await ctx.close();246  });247248  test("LS20 - unlock shows loading state", async () => {249    const { popup } = await setupVault(ctx);250    await lockVault(popup);251    await popup.locator("#unlock-password").fill(VAULT_PASSWORD);252    const unlockBtn = popup.getByRole("button", { name: /unlock/i });253    await unlockBtn.click();254    await popup.waitForTimeout(300);255    await ctx.close();256  });257258  test("LS21 - closing the popup mid-onboarding resumes at the same card", async () => {259    const { popup } = await loadPopup(ctx);260    const headline = popup.locator(".peach-onboarding-card-headline");261    await expect(headline).toContainText("Welcome to");262    await popup.locator(".peach-onboarding-next").click();263    await expect(headline).toContainText("Two keys.", { timeout: 3000 });264    // Popup dismiss mid-flow: the card position must survive the close.265    await popup.close();266267    const { popup: reopened } = await loadPopup(ctx);268    await expect(reopened.locator(".peach-onboarding-card-headline")).toContainText("Two keys.", {269      timeout: 5000,270    });271    await ctx.close();272  });273});274275test.describe("Lock Screen - Auto-lock Configuration", () => {276  let ctx: BrowserContext;277  test.afterEach(async () => { await ctx.close(); });278  test.beforeEach(async () => { ctx = await makeContext(); });279280  // The auto-lock control is a native <select> inside the (initially281  // concealed) Protection card. Option/value assertions read the DOM directly,282  // which works regardless of the card-deck animation; LAC08 uses a forced283  // selectOption to set the value without driving the deck.284285  test("LAC01 - auto-lock defaults to 5 minutes", async () => {286    const { popup } = await setupVault(ctx);287    await openSettings(popup);288    await expect(autoLockSelect(popup)).toHaveValue("5");289    await ctx.close();290  });291292  test("LAC02 - auto-lock options available", async () => {293    const { popup } = await setupVault(ctx);294    await openSettings(popup);295    await expect(autoLockSelect(popup).locator("option")).toHaveCount(5);296    await ctx.close();297  });298299  test("LAC03 - lock on close option present", async () => {300    const { popup } = await setupVault(ctx);301    await openSettings(popup);302    await expect(303      popup.locator(".peach-settings-row", { hasText: "Lock on close" }),304    ).toHaveCount(1);305    await ctx.close();306  });307308  // Assert by option value + exact text. hasText is a substring match, so309  // "5 minutes" would also match "1<5 minutes>" — value scoping avoids that.310  test("LAC04 - never auto-lock option available", async () => {311    const { popup } = await setupVault(ctx);312    await openSettings(popup);313    await expect(autoLockSelect(popup).locator('option[value="0"]')).toHaveText("Never");314    await ctx.close();315  });316317  test("LAC05 - 5 minute auto-lock option", async () => {318    const { popup } = await setupVault(ctx);319    await openSettings(popup);320    await expect(autoLockSelect(popup).locator('option[value="5"]')).toHaveText("5 minutes");321    await ctx.close();322  });323324  test("LAC06 - 15 minute auto-lock option", async () => {325    const { popup } = await setupVault(ctx);326    await openSettings(popup);327    await expect(autoLockSelect(popup).locator('option[value="15"]')).toHaveText("15 minutes");328    await ctx.close();329  });330331  test("LAC07 - 30 minute auto-lock option", async () => {332    // The control maxes at 30 minutes — there is no "1 hour" option.333    const { popup } = await setupVault(ctx);334    await openSettings(popup);335    await expect(autoLockSelect(popup).locator('option[value="30"]')).toHaveText("30 minutes");336    await ctx.close();337  });338339  test("LAC08 - changing auto-lock persists across reload", async () => {340    const { popup, extId } = await setupVault(ctx);341    await openSettings(popup);342    // force: bypass the card-deck animation; selectOption still fires onChange.343    await autoLockSelect(popup).selectOption("15", { force: true });344    await expect(autoLockSelect(popup)).toHaveValue("15");345346    // The setting is read back from storage on mount, so a reload proves it stuck.347    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);348    await popup.waitForTimeout(1500);349    if (await popup.locator("#unlock-password").isVisible().catch(() => false)) {350      await unlockVault(popup);351    }352    await openSettings(popup);353    await expect(autoLockSelect(popup)).toHaveValue("15");354    await ctx.close();355  });356});357358test.describe("Lock Screen - Security", () => {359  let ctx: BrowserContext;360  test.afterEach(async () => { await ctx.close(); });361  test.beforeEach(async () => { ctx = await makeContext(); });362363  test("LSC01 - no sensitive data in lock screen DOM", async () => {364    const { popup } = await setupVault(ctx);365    await lockVault(popup);366    const html = await popup.content();367    expect(html).not.toContain(VAULT_PASSWORD);368    expect(html).not.toContain("argon2");369    await ctx.close();370  });371372  test("LSC02 - lock screen has CSP compliance", async () => {373    const { popup } = await setupVault(ctx);374    await lockVault(popup);375    const csp = await popup.evaluate(() => {376      const meta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');377      return meta ? meta.getAttribute("content") : null;378    });379    if (csp) expect(csp).toContain("script-src");380    await ctx.close();381  });382383  test("LSC03 - session storage cleared on lock", async () => {384    const { popup } = await setupVault(ctx);385    await lockVault(popup);386    await popup.waitForTimeout(500);387    await ctx.close();388  });389390  test("LSC04 - memory cleared on lock", async () => {391    const { popup } = await setupVault(ctx);392    await lockVault(popup);393    const cleared = await popup.evaluate(() => {394      return typeof window.sessionStorage !== "undefined";395    });396    expect(cleared).toBeTruthy();397    await ctx.close();398  });399400  test("LSC05 - correct password still unlocks after wrong attempts", async () => {401    const { popup } = await setupVault(ctx);402    await lockVault(popup);403    for (let i = 0; i < 3; i++) {404      await popup.locator("#unlock-password").fill("wrongpassword");405      await popup.getByRole("button", { name: /unlock/i }).click();406      // A wrong password keeps us on the lock screen (each unlock runs Argon2,407      // so wait for the input to settle before the next attempt).408      await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 });409      await popup.waitForTimeout(500);410    }411    // The correct password must still work — wrong attempts don't lock you out.412    await unlockVault(popup);413    await ctx.close();414  });415416  test("LSC06 - lock requires user gesture", async () => {417    const { popup } = await setupVault(ctx);418    const lockBtn = popup.locator(UNLOCKED);419    await expect(lockBtn).toBeVisible();420    await ctx.close();421  });422});