import { test, expect, Page, BrowserContext } from "@playwright/test"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import os from "os"; import fs from "fs/promises"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const EXTENSION_PATH = join(__dirname, "..", "dist"); // Every test creates a real vault (onboarding + Argon2id KDF + recovery // confirmation ≈ 12s), and lock/unlock cycles run Argon2 again. The global // 30s cap is too tight for the multi-cycle cases; raise it for this file. test.describe.configure({ timeout: 90_000 }); async function waitForSW(ctx: BrowserContext, maxWait = 15000): Promise { const start = Date.now(); let sw = ctx.serviceWorkers()[0]; while (!sw && Date.now() - start < maxWait) { await new Promise(r => setTimeout(r, 500)); sw = ctx.serviceWorkers()[0]; } if (!sw) throw new Error(`No SW after ${maxWait}ms`); return new URL(sw.url()).host; } async function loadPopup(ctx: BrowserContext): Promise<{ popup: Page, extId: string }> { const extId = await waitForSW(ctx); const popup = await ctx.newPage(); await popup.goto(`chrome-extension://${extId}/src/popup/index.html`); await popup.waitForTimeout(3000); return { popup, extId }; } async function makeContext() { const { chromium } = await import("playwright"); const userDataDir = join(os.tmpdir(), `peach-${Date.now()}-${Math.random().toString(36).slice(2)}`); await fs.mkdir(userDataDir, { recursive: true }); return chromium.launchPersistentContext(userDataDir, { headless: false, args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`], }); } // "password123" was both too short (<12) and too weak for the create-vault // strength gate, so the old default never actually created a vault — the // "create vault" button stayed in its blocker state and every downstream // lock/unlock test failed. VAULT_PASSWORD passes the gate. setupVault also // completes the recovery-words confirmation by default so callers land in // the unlocked vault; LS07b/c/d opt out to stay on the recovery screen. const VAULT_PASSWORD = "correct horse battery staple"; async function setupVault( ctx: BrowserContext, password = VAULT_PASSWORD, opts: { confirmRecovery?: boolean } = {}, ) { const { confirmRecovery = true } = opts; const { popup, extId } = await loadPopup(ctx); const advanceButton = popup.getByRole("button", { name: /next|get started/i }); // Create screen uses #master-password; the unlock/lock screen uses // #unlock-password. Keep this one pointed at the create field. const pw = popup.locator("#master-password"); for (let i = 0; i < 5 && !(await pw.isVisible().catch(() => false)); i++) { if (await advanceButton.isVisible({ timeout: 500 }).catch(() => false)) { await advanceButton.click(); await popup.waitForTimeout(300); continue; } await popup.locator(".peach-onboarding-card").click(); await popup.waitForTimeout(300); } if (await pw.isVisible()) { await pw.fill(password); await popup.locator("#confirm-password").fill(password); await popup.getByRole("button", { name: /create vault/i }).click(); // Vault creation runs Argon2id, which can exceed a few seconds under load — // give the recovery display room so this doesn't flake. await expect(popup.locator(".lock-screen--recovery-display")).toBeVisible({ timeout: 20000 }); if (confirmRecovery) await completeRecoveryConfirmation(popup); } return { popup, extId }; } async function completeRecoveryConfirmation(popup: Page) { const recoveryWords = popup.locator(".peach-recovery-word-text"); const confirmInputs = popup.locator(".peach-recovery-confirm-input"); const confirmButton = popup.getByRole("button", { name: /i wrote them down/i }); await expect(confirmInputs).toHaveCount(3); await expect(confirmInputs.first()).toBeEnabled({ timeout: 15000 }); const words = await recoveryWords.allTextContents(); const confirmedWords = [words[2], words[6], words[10]]; for (let i = 0; i < confirmedWords.length; i++) { const word = confirmedWords[i]?.trim(); if (!word) throw new Error(`missing recovery word at position ${i}`); await confirmInputs.nth(i).fill(word); } await expect(confirmButton).toBeEnabled({ timeout: 2000 }); await confirmButton.click(); await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 5000 }); } async function lockVault(popup: Page) { const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']"); await expect(lockBtn).toBeVisible({ timeout: 15000 }); await lockBtn.click(); await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 }); } async function unlockVault(popup: Page, password = VAULT_PASSWORD) { await popup.locator("#unlock-password").fill(password); await popup.getByRole("button", { name: /unlock/i }).click(); await expect(popup.locator(".peach-lock-btn[aria-label='Lock vault']")).toBeVisible({ timeout: 20000 }); } async function openSettings(popup: Page) { await popup.locator(".peach-icon-btn[aria-label='Settings']").click(); await expect(popup.locator(".stk-shell")).toBeVisible({ timeout: 10000 }); } // The auto-lock control is a native inside the (initially // concealed) Protection card. Option/value assertions read the DOM directly, // which works regardless of the card-deck animation; LAC08 uses a forced // selectOption to set the value without driving the deck. test("LAC01 - auto-lock defaults to 5 minutes", async () => { const { popup } = await setupVault(ctx); await openSettings(popup); await expect(autoLockSelect(popup)).toHaveValue("5"); await ctx.close(); }); test("LAC02 - auto-lock options available", async () => { const { popup } = await setupVault(ctx); await openSettings(popup); await expect(autoLockSelect(popup).locator("option")).toHaveCount(5); await ctx.close(); }); test("LAC03 - lock on close option present", async () => { const { popup } = await setupVault(ctx); await openSettings(popup); await expect( popup.locator(".peach-settings-row", { hasText: "Lock on close" }), ).toHaveCount(1); await ctx.close(); }); // Assert by option value + exact text. hasText is a substring match, so // "5 minutes" would also match "1<5 minutes>" — value scoping avoids that. test("LAC04 - never auto-lock option available", async () => { const { popup } = await setupVault(ctx); await openSettings(popup); await expect(autoLockSelect(popup).locator('option[value="0"]')).toHaveText("Never"); await ctx.close(); }); test("LAC05 - 5 minute auto-lock option", async () => { const { popup } = await setupVault(ctx); await openSettings(popup); await expect(autoLockSelect(popup).locator('option[value="5"]')).toHaveText("5 minutes"); await ctx.close(); }); test("LAC06 - 15 minute auto-lock option", async () => { const { popup } = await setupVault(ctx); await openSettings(popup); await expect(autoLockSelect(popup).locator('option[value="15"]')).toHaveText("15 minutes"); await ctx.close(); }); test("LAC07 - 30 minute auto-lock option", async () => { // The control maxes at 30 minutes — there is no "1 hour" option. const { popup } = await setupVault(ctx); await openSettings(popup); await expect(autoLockSelect(popup).locator('option[value="30"]')).toHaveText("30 minutes"); await ctx.close(); }); test("LAC08 - changing auto-lock persists across reload", async () => { const { popup, extId } = await setupVault(ctx); await openSettings(popup); // force: bypass the card-deck animation; selectOption still fires onChange. await autoLockSelect(popup).selectOption("15", { force: true }); await expect(autoLockSelect(popup)).toHaveValue("15"); // The setting is read back from storage on mount, so a reload proves it stuck. await popup.goto(`chrome-extension://${extId}/src/popup/index.html`); await popup.waitForTimeout(1500); if (await popup.locator("#unlock-password").isVisible().catch(() => false)) { await unlockVault(popup); } await openSettings(popup); await expect(autoLockSelect(popup)).toHaveValue("15"); await ctx.close(); }); }); test.describe("Lock Screen - Security", () => { let ctx: BrowserContext; test.afterEach(async () => { await ctx.close(); }); test.beforeEach(async () => { ctx = await makeContext(); }); test("LSC01 - no sensitive data in lock screen DOM", async () => { const { popup } = await setupVault(ctx); await lockVault(popup); const html = await popup.content(); expect(html).not.toContain(VAULT_PASSWORD); expect(html).not.toContain("argon2"); await ctx.close(); }); test("LSC02 - lock screen has CSP compliance", async () => { const { popup } = await setupVault(ctx); await lockVault(popup); const csp = await popup.evaluate(() => { const meta = document.querySelector('meta[http-equiv="Content-Security-Policy"]'); return meta ? meta.getAttribute("content") : null; }); if (csp) expect(csp).toContain("script-src"); await ctx.close(); }); test("LSC03 - session storage cleared on lock", async () => { const { popup } = await setupVault(ctx); await lockVault(popup); await popup.waitForTimeout(500); await ctx.close(); }); test("LSC04 - memory cleared on lock", async () => { const { popup } = await setupVault(ctx); await lockVault(popup); const cleared = await popup.evaluate(() => { return typeof window.sessionStorage !== "undefined"; }); expect(cleared).toBeTruthy(); await ctx.close(); }); test("LSC05 - correct password still unlocks after wrong attempts", async () => { const { popup } = await setupVault(ctx); await lockVault(popup); for (let i = 0; i < 3; i++) { await popup.locator("#unlock-password").fill("wrongpassword"); await popup.getByRole("button", { name: /unlock/i }).click(); // A wrong password keeps us on the lock screen (each unlock runs Argon2, // so wait for the input to settle before the next attempt). await expect(popup.locator("#unlock-password")).toBeVisible({ timeout: 15000 }); await popup.waitForTimeout(500); } // The correct password must still work — wrong attempts don't lock you out. await unlockVault(popup); await ctx.close(); }); test("LSC06 - lock requires user gesture", async () => { const { popup } = await setupVault(ctx); const lockBtn = popup.locator(".peach-lock-btn[aria-label='Lock vault']"); await expect(lockBtn).toBeVisible(); await ctx.close(); }); });