1import { chromium, test, expect, BrowserContext } from "@playwright/test";2import { fileURLToPath } from "url";3import { dirname, join } from "path";4import os from "os";5import fs from "fs/promises";6import {7 EXTENSION_PATH,8 UNLOCKED,9 createEntryViaForm,10 lockVault,11 makeContext,12 listSearch,13 openList,14 openSettings,15 setupVault,16 waitForSW,17} from "./helpers/vault";1819const __filename = fileURLToPath(import.meta.url);20const __dirname = dirname(__filename);212223test.describe.configure({ timeout: 90_000 });242526async function openGenerator(popup: import("@playwright/test").Page) {27 await openList(popup);28 await popup.locator(".peach-new-entry-row").click();29 await popup.locator(".peach-generate-toggle-link").click();30 await expect(popup.locator(".peach-pwgen-compact-output")).toBeVisible({ timeout: 5000 });31}3233test.describe("XSS Prevention", () => {34 let ctx: BrowserContext;35 test.afterEach(async () => { await ctx.close(); });36 test.beforeEach(async () => { ctx = await makeContext(); });3738 test("SECX01 - entry titles are escaped in DOM", async () => {39 const { popup } = await setupVault(ctx);40 await createEntryViaForm(popup, { title: '<script>alert("xss")</script>', url: "https://xss.com", username: "[email protected]", password: "xss" });41 42 43 44 45 const entryName = popup.locator(".peach-entry-name").first();46 expect(await entryName.textContent()).toContain("alert");47 expect(await popup.locator("script:not([src])").count()).toBe(0);48 await ctx.close();49 });5051 test("SECX02 - entry URLs are sanitized before display", async () => {52 const { popup } = await setupVault(ctx);53 await createEntryViaForm(popup, { title: "URLTest", url: 'javascript:alert("xss")', username: "[email protected]", password: "pass" });54 55 56 await expect(popup.locator(".peach-domain-value").first()).toBeVisible();57 expect(await popup.locator('a[href^="javascript:"]').count()).toBe(0);58 await ctx.close();59 });6061 test("SECX03 - no inline event handlers in entry rendering", async () => {62 const { popup } = await setupVault(ctx);63 await createEntryViaForm(popup, { title: '" onmouseover="alert(1)" "', url: "https://test.com", username: "[email protected]", password: "pass" });64 65 66 const injected = await popup.evaluate(() =>67 document.querySelectorAll("[onmouseover]").length,68 );69 expect(injected).toBe(0);70 await ctx.close();71 });7273 test("SECX04 - notes field renders without HTML execution", async () => {74 const { popup } = await setupVault(ctx);75 await createEntryViaForm(popup, { title: "NotesXSS", url: "https://notes.com", username: "[email protected]", password: "pass", notes: '<img src=x onerror="alert(1)">' });76 77 78 await expect(popup.locator(".peach-note-content")).toContainText("onerror");79 expect(await popup.locator(".peach-note-content img").count()).toBe(0);80 expect(await popup.evaluate(() => document.querySelectorAll("[onerror]").length)).toBe(0);81 await ctx.close();82 });8384 test("SECX05 - search input sanitizes query before display", async () => {85 const { popup } = await setupVault(ctx);86 await createEntryViaForm(popup, { title: "SearchTest", url: "https://search.com", username: "[email protected]", password: "pass" });87 await openList(popup);88 const searchInput = listSearch(popup);89 await searchInput.fill('<script>alert("xss")</script>');90 await popup.waitForTimeout(300);91 92 93 expect(await searchInput.inputValue()).toContain("<script>");94 expect(await popup.locator("script:not([src])").count()).toBe(0);95 await ctx.close();96 });97});9899test.describe("Session Security", () => {100 let ctx: BrowserContext;101 test.afterEach(async () => { await ctx.close(); });102 test.beforeEach(async () => { ctx = await makeContext(); });103104 test("SECS01 - session key cleared on lock", async () => {105 const { popup } = await setupVault(ctx);106 await lockVault(popup);107 108 109 const response = await popup.evaluate(async () => {110 return new Promise<{ ok: boolean; data: boolean }>(resolve => {111 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));112 });113 });114 expect(response.data).toBe(false);115 await ctx.close();116 });117118 test("SECS02 - session does not persist across browser restarts", async () => {119 const userDataDir = join(os.tmpdir(), `peach-sec-restart-${Date.now()}`);120 await fs.mkdir(userDataDir, { recursive: true });121 const launch = () => chromium.launchPersistentContext(userDataDir, {122 executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,123 headless: false,124 args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],125 });126127 const first = await launch();128 const { popup } = await setupVault(first);129 await createEntryViaForm(popup, { title: "PersistSession", url: "https://persist.com", username: "[email protected]", password: "pass" });130 await first.close();131132 const second = await launch();133 const extId = await waitForSW(second);134 const reopened = await second.newPage();135 await reopened.goto(`chrome-extension://${extId}/src/popup/index.html`);136 await expect(reopened.locator("#unlock-password")).toBeVisible({ timeout: 20000 });137 await expect(reopened.locator(UNLOCKED)).toHaveCount(0);138 await second.close();139 });140141 test("SECS03 - idle timeout clears session", async () => {142 const { popup } = await setupVault(ctx);143 await openSettings(popup);144 await popup.locator('[data-settings-card-id="protection"]').click();145 await expect(146 popup.locator(".peach-settings-row", { hasText: "Auto-lock after" }),147 ).toHaveCount(1);148 await ctx.close();149 });150151 test("SECS04 - master password not stored in memory after unlock", async () => {152 const { popup } = await setupVault(ctx);153 const hasStoredPW = await popup.evaluate(() => {154 return (window as any).__PEACH_MASTER_PASSWORD !== undefined ||155 (window as any).masterPassword !== undefined;156 });157 expect(hasStoredPW).toBeFalsy();158 await ctx.close();159 });160161 test("SECS05 - vault data not accessible without unlock", async () => {162 const { popup } = await setupVault(ctx);163 await createEntryViaForm(popup, { title: "LockedRead", url: "https://locked.test", username: "u", password: "p" });164 await lockVault(popup);165 const response = await popup.evaluate(async () => {166 return new Promise<{ ok: boolean; error?: string; data?: unknown }>(resolve => {167 chrome.runtime.sendMessage({ type: "GET_ENTRIES" }, r => resolve(r));168 });169 });170 expect(response.ok).toBe(false);171 expect(response.error).toBe("vault_locked");172 expect(response.data).toBeUndefined();173 await ctx.close();174 });175176 test("SECS06 - service worker message validates sender origin", async () => {177 const ctx2 = await makeContext();178 const { popup } = await setupVault(ctx);179 const response = await popup.evaluate(async () => {180 return new Promise(resolve => {181 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));182 });183 });184 expect(response).toBeDefined();185 await ctx.close();186 await ctx2.close();187 });188189 test("SECS07 - no sensitive data in extension localStorage", async () => {190 const { popup } = await setupVault(ctx);191 await popup.waitForTimeout(1000);192 const storage = await popup.evaluate(() => {193 return new Promise(resolve => {194 chrome.storage.local.get(null, r => resolve(r));195 });196 }) as any;197 if (storage && typeof storage === 'object') {198 const hasSensitive = Object.keys(storage).some(k =>199 k.toLowerCase().includes('password') ||200 k.toLowerCase().includes('secret') ||201 k.toLowerCase().includes('key')202 );203 expect(hasSensitive).toBeFalsy();204 }205 await ctx.close();206 });207});208209test.describe("CSP Compliance", () => {210 let ctx: BrowserContext;211 test.afterEach(async () => { await ctx.close(); });212 test.beforeEach(async () => { ctx = await makeContext(); });213214 test("SEQC01 - no inline scripts in popup HTML", async () => {215 const extId = await waitForSW(ctx);216 const page = await ctx.newPage();217 await page.goto(`chrome-extension://${extId}/src/popup/index.html`);218 await page.waitForTimeout(2000);219 const scripts = await page.evaluate(() => {220 const html = document.documentElement.innerHTML;221 const scriptMatches = html.match(/<script[^>]*>/gi) || [];222 return scriptMatches.filter(s => !s.includes('src='));223 });224 expect(scripts.length).toBe(0);225 await ctx.close();226 });227228 test("SEQC02 - no eval() in content script", async () => {229 const distFiles = await fs.readdir(join(__dirname, "..", "dist-chrome", "assets"));230 const jsFiles = distFiles.filter(f => f.endsWith(".js"));231 let foundEval = false;232 for (const file of jsFiles) {233 const content = await fs.readFile(join(__dirname, "..", "dist-chrome", "assets", file), "utf-8");234 if (content.includes("eval(") && !content.includes("new Function")) {235 foundEval = true;236 break;237 }238 }239 expect(foundEval).toBeFalsy();240 await ctx.close();241 });242243 test("SEQC03 - CSP meta tag present in HTML", async () => {244 const manifest = JSON.parse(245 await fs.readFile(join(__dirname, "..", "dist-chrome", "manifest.json"), "utf-8"),246 ) as { content_security_policy?: { extension_pages?: string } };247 expect(manifest.content_security_policy?.extension_pages).toContain("script-src 'self'");248 await ctx.close();249 });250251});252253test.describe("Encryption Verification", () => {254 let ctx: BrowserContext;255 test.afterEach(async () => { await ctx.close(); });256 test.beforeEach(async () => { ctx = await makeContext(); });257258259 test("SECE02 - extension page supports AES-GCM WebCrypto", async () => {260 const extId = await waitForSW(ctx);261 const page = await ctx.newPage();262 await page.goto(`chrome-extension://${extId}/src/popup/index.html`);263 const plaintext = "Peach WebCrypto";264 const decrypted = await page.evaluate(async (value) => {265 const key = await crypto.subtle.generateKey(266 { name: "AES-GCM", length: 256 },267 true,268 ["encrypt", "decrypt"],269 );270 const iv = crypto.getRandomValues(new Uint8Array(12));271 const ciphertext = await crypto.subtle.encrypt(272 { name: "AES-GCM", iv },273 key,274 new TextEncoder().encode(value),275 );276 const recovered = await crypto.subtle.decrypt(277 { name: "AES-GCM", iv },278 key,279 ciphertext,280 );281 return new TextDecoder().decode(recovered);282 }, plaintext);283 expect(decrypted).toBe(plaintext);284 await ctx.close();285 });286287288289 test("SECE05 - salt generated for each encryption operation", async () => {290 const distFiles = await fs.readdir(join(__dirname, "..", "dist-chrome", "assets"));291 const jsFiles = distFiles.filter(f => f.endsWith(".js"));292 let hasSaltGen = false;293 for (const file of jsFiles) {294 const content = await fs.readFile(join(__dirname, "..", "dist-chrome", "assets", file), "utf-8");295 if (content.includes("generateSalt") || content.includes("getRandomValues")) {296 hasSaltGen = true;297 break;298 }299 }300 expect(hasSaltGen).toBeTruthy();301 await ctx.close();302 });303304 test("SECE06 - key stored in secure memory not plain", async () => {305 const { popup } = await setupVault(ctx);306 await popup.waitForTimeout(1000);307 const keyInDOM = await popup.evaluate(() => {308 const inputs = document.querySelectorAll("input");309 for (const input of inputs) {310 if (input.value && input.value.length > 20 && !input.type) {311 return true;312 }313 }314 return false;315 });316 expect(keyInDOM).toBeFalsy();317 await ctx.close();318 });319320 test("SECE07 - export uses PBKDF2 with high iterations", async () => {321 const exportFile = await fs.readdir(join(__dirname, "..", "dist-chrome", "assets")).then(f =>322 f.find(f => f.includes("export") || f.includes("Export"))323 );324 expect(exportFile).toBeDefined();325 const content = await fs.readFile(join(__dirname, "..", "dist-chrome", "assets", exportFile!), "utf-8");326 if (content.includes("PBKDF2")) {327 expect(content).toMatch(/iterations.*[0-9]{4,}|250000/);328 }329 await ctx.close();330 });331});332333334335336test.describe("CSRF Protection", () => {337 let ctx: BrowserContext;338 test.afterEach(async () => { await ctx.close(); });339 test.beforeEach(async () => { ctx = await makeContext(); });340341 test("SECC01 - service worker rejects cross-origin messages", async () => {342 const { popup } = await setupVault(ctx);343 const response = await popup.evaluate(async () => {344 return new Promise(resolve => {345 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));346 });347 });348 expect(response).toBeDefined();349 await ctx.close();350 });351352 test("SECC02 - no token exposure in URL parameters", async () => {353 const { popup } = await setupVault(ctx);354 await createEntryViaForm(popup, { title: "URLToken", url: "https://token.com", username: "[email protected]", password: "pass" });355 const url = popup.url();356 expect(url).not.toMatch(/token|key|secret|session/i);357 await ctx.close();358 });359360});361362test.describe("Breach Detection Security", () => {363 let ctx: BrowserContext;364 test.afterEach(async () => { await ctx.close(); });365 test.beforeEach(async () => { ctx = await makeContext(); });366367 test("SECB01 - HIBP API call uses k-anonymity model", async () => {368 const distFiles = await fs.readdir(join(__dirname, "..", "dist-chrome", "assets"));369 const jsFiles = distFiles.filter(f => f.endsWith(".js"));370 let foundHIBP = false;371 for (const file of jsFiles) {372 const content = await fs.readFile(join(__dirname, "..", "dist-chrome", "assets", file), "utf-8");373 if (content.includes("hibp") || content.includes("pwned")) {374 foundHIBP = true;375 expect(content).toMatch(/range|hash|k-anon|sha1/i);376 break;377 }378 }379 expect(foundHIBP).toBeTruthy();380 await ctx.close();381 });382383 test("SECB02 - breach check does not expose full password", async () => {384 const distFiles = await fs.readdir(join(__dirname, "..", "dist-chrome", "assets"));385 const jsFiles = distFiles.filter(f => f.endsWith(".js"));386 let foundHash = false;387 for (const file of jsFiles) {388 const content = await fs.readFile(join(__dirname, "..", "dist-chrome", "assets", file), "utf-8");389 if (content.includes("breach") || content.includes("hibp")) {390 if (content.includes("sha1") || content.includes("SHA-1")) {391 foundHash = true;392 break;393 }394 }395 }396 expect(foundHash).toBeTruthy();397 await ctx.close();398 });399400 test("SECB03 - breach status stored securely", async () => {401 const { popup } = await setupVault(ctx);402 await popup.waitForTimeout(500);403 const storage = await popup.evaluate(() => {404 return new Promise(resolve => {405 chrome.storage.local.get(null, r => resolve(r));406 });407 }) as any;408 if (storage) {409 const hasBreachClearText = JSON.stringify(storage).includes("breached");410 expect(hasBreachClearText).toBeFalsy();411 }412 await ctx.close();413 });414});415416test.describe("Password Generation Security", () => {417 let ctx: BrowserContext;418 test.afterEach(async () => { await ctx.close(); });419 test.beforeEach(async () => { ctx = await makeContext(); });420421 test("SECP01 - generator uses crypto.getRandomValues", async () => {422 const { popup } = await setupVault(ctx);423 await openGenerator(popup);424 const pwd1 = await popup.locator(".peach-pwgen-compact-output").inputValue();425 expect(pwd1.length).toBeGreaterThan(0);426 await ctx.close();427 });428429 test("SECP02 - generated passwords not logged", async () => {430 const { popup } = await setupVault(ctx);431 await openGenerator(popup);432 const pwd = await popup.locator(".peach-pwgen-compact-output").inputValue();433 expect(pwd).not.toContain("console.log");434 await ctx.close();435 });436437 test("SECP03 - password entropy meets minimum threshold", async () => {438 const { popup } = await setupVault(ctx);439 await openGenerator(popup);440 const slider = popup.locator(".peach-pwgen-slider");441 await slider.fill("32");442 await expect(popup.locator(".peach-pwgen-len-num")).toHaveText("32");443 const pwd = await popup.locator(".peach-pwgen-compact-output").inputValue();444 expect(pwd.length).toBe(32);445 const uniqueChars = new Set(pwd.split("")).size;446 expect(uniqueChars).toBeGreaterThan(10);447 await ctx.close();448 });449});