1import { test, expect, BrowserContext } from "@playwright/test";2import { fileURLToPath } from "url";3import { dirname, join } from "path";4import os from "os";5import fs from "fs/promises";67const __filename = fileURLToPath(import.meta.url);8const __dirname = dirname(__filename);9const EXTENSION_PATH = join(__dirname, "..", "dist");1011async function makeContext() {12 const { chromium } = await import("playwright");13 const userDataDir = join(os.tmpdir(), `peach-sec-${Date.now()}`);14 await fs.mkdir(userDataDir, { recursive: true });15 return chromium.launchPersistentContext(userDataDir, {16 headless: false,17 args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],18 });19}2021async function waitForSW(ctx: BrowserContext, maxWait = 15000): Promise<string> {22 const start = Date.now();23 let sw = ctx.serviceWorkers()[0];24 while (!sw && Date.now() - start < maxWait) {25 await new Promise(r => setTimeout(r, 500));26 sw = ctx.serviceWorkers()[0];27 }28 if (!sw) throw new Error(`No SW after ${maxWait}ms`);29 return new URL(sw.url()).host;30}3132async function setupVault(ctx: BrowserContext): Promise<{ popup: any, extId: string }> {33 const extId = await waitForSW(ctx);34 const popup = await ctx.newPage();35 await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);36 await popup.waitForTimeout(3000);37 const pw = popup.locator("#master-password");38 if (await pw.isVisible()) {39 await pw.fill("password123");40 await popup.locator("#confirm-password").fill("password123");41 await popup.getByRole("button", { name: /create vault/i }).click();42 await popup.waitForTimeout(2000);43 }44 return { popup, extId };45}4647test.describe("Security Tests", () => {4849 test("SEC02 - crypto.random used instead of Math.random", async () => {50 const distFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));51 const jsFiles = distFiles.filter(f => f.endsWith(".js"));52 for (const file of jsFiles) {53 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");54 const mathRandomMatches = content.match(/Math\.random\(\)/g) || [];55 const cryptoRandomMatches = content.match(/crypto\.getRandomValues\(/g) || [];56 if (mathRandomMatches.length > 0) {57 expect(cryptoRandomMatches.length).toBeGreaterThanOrEqual(mathRandomMatches.length);58 }59 }60 });6162 test("SEC03 - service worker sender validation", async () => {63 const { popup } = await setupVault(await makeContext());64 const response = await popup.evaluate(async () => {65 return new Promise(resolve => {66 chrome.runtime.sendMessage({ type: "IS_UNLOCKED" }, r => resolve(r));67 });68 });69 expect(response).toBeDefined();70 });7172 test("SEC04 - manifest has correct permissions", async () => {73 const manifest = JSON.parse(await fs.readFile(join(__dirname, "..", "dist", "manifest.json"), "utf-8"));74 const allowed = ["storage", "activeTab", "scripting", "clipboardWrite", "alarms", "idle"];75 const extra = manifest.permissions.filter((p: string) => !allowed.includes(p));76 expect(extra).toHaveLength(0);77 });7879 test("SEC05 - no console.log in crypto paths", async () => {80 const cryptoFiles = await fs.readdir(join(__dirname, "..", "dist", "assets"));81 const jsFiles = cryptoFiles.filter(f => f.endsWith(".js"));82 for (const file of jsFiles) {83 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");84 const lines = content.split("\n");85 for (let i = 0; i < lines.length; i++) {86 const l = lines[i];87 if (l.includes("console.log") && (l.includes("crypto") || l.includes("encrypt") || l.includes("argon") || l.includes("pbkdf"))) {88 expect(l).not.toMatch(/console\.log/);89 }90 }91 }92 });9394 test("SEC06 - TOTP uses SHA-256 or SHA-512", async () => {95 const totpFile = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>96 f.find(f => f.includes("totp"))97 );98 expect(totpFile).toBeDefined();99 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", totpFile!), "utf-8");100 expect(content).toMatch(/SHA-256|SHA-512|sha256|sha512|createHash/);101 });102103 test("SEC07 - clipboard operations require explicit user action", async () => {104 const ctx = await makeContext();105 const { popup } = await setupVault(ctx);106 await popup.locator('input[placeholder="search vault..."]').click();107 await popup.getByRole("button", { name: /new entry/i }).click();108 await popup.locator(".peach-new-name-input").fill("SecureTest");109 await popup.locator('input[placeholder="domain.com"]').fill("https://secure.com");110 await popup.locator('input[placeholder="username or email"]').fill("[email protected]");111 await popup.locator(".peach-new-field-input[placeholder='password']").fill("securepass");112 await popup.getByRole("button", { name: /create/i }).click();113 await popup.waitForTimeout(1500);114 await popup.locator("text=SecureTest").first().click();115 await popup.waitForTimeout(500);116 const copyBtn = popup.locator(".peach-copy-btn").first();117 let clicked = false;118 try {119 await copyBtn.click({ timeout: 2000 });120 clicked = true;121 } catch {}122 expect(clicked).toBe(true);123 await ctx.close();124 });125126 test("SEC08 - export requires password", async () => {127 const ctx = await makeContext();128 const { popup } = await setupVault(ctx);129 await popup.locator(".peach-icon-btn[aria-label='Settings']").click();130 await popup.waitForTimeout(300);131 await popup.getByRole("button", { name: /export/i }).click();132 await popup.waitForTimeout(500);133 const exportPw = popup.locator('input[placeholder*="password" i]');134 await expect(exportPw).toBeVisible();135 await ctx.close();136 });137138 test("SEC09 - vault locks on idle timeout", async () => {139 const ctx = await makeContext();140 const { popup } = await setupVault(ctx);141 await popup.locator(".peach-icon-btn[aria-label='Settings']").click();142 await popup.waitForTimeout(300);143 const autoLockRow = popup.locator("text=auto-lock").first();144 await expect(autoLockRow).toBeVisible();145 await ctx.close();146 });147148 test("SEC10 - no sensitive data in error messages", async () => {149 const ctx = await makeContext();150 await setupVault(ctx);151 const newPopup = await ctx.newPage();152 const extId = (await waitForSW(ctx));153 await newPopup.goto(`chrome-extension://${extId}/src/popup/index.html`);154 await newPopup.waitForTimeout(2000);155 await newPopup.locator("#master-password").fill("wrongpassword");156 await newPopup.getByRole("button", { name: /unlock/i }).click();157 await newPopup.waitForTimeout(500);158 const hint = newPopup.locator(".peach-hint-error, .peach-hint").first();159 const hintText = await hint.textContent().catch(() => "") ?? "";160 expect(hintText.toLowerCase()).not.toMatch(/password|secret|key|master/);161 await ctx.close();162 });163});164165test.describe("CSS Visual Compliance", () => {166167 test("CSS01 - only animatable properties used in animations", async () => {168 const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>169 f.filter(f => f.endsWith(".css"))170 );171 const animatableProps = ["opacity", "transform", "color", "stroke", "stroke-dashoffset", "width", "max-height", "height"];172 for (const file of cssFiles) {173 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");174 const keyframes = content.match(/@keyframes[\s\S]*?\{[\s\S]*?\}/g) || [];175 for (const kf of keyframes) {176 const props = kf.match(/[\w-]+:\s*[^;]+;/g) || [];177 for (const prop of props) {178 const name = prop.split(":")[0].trim();179 if (!animatableProps.includes(name)) {180 expect(name).toBe("");181 }182 }183 }184 }185 });186187 test("CSS02 - only --spring, --retreat, --settle easing variables", async () => {188 const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>189 f.filter(f => f.endsWith(".css"))190 );191 for (const file of cssFiles) {192 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");193 const cubicBezierMatches = content.match(/cubic-bezier\([^)]+\)/g) || [];194 for (const cb of cubicBezierMatches) {195 expect(content).toMatch(new RegExp(`--spring|--retreat|--settle|.${cb}.|`));196 }197 }198 });199200 test("CSS03 - no Google Fonts CDN links", async () => {201 const html = await fs.readFile(join(__dirname, "..", "dist", "src", "popup", "index.html"), "utf-8");202 expect(html).not.toMatch(/fonts\.googleapis\.com|fonts\.gstatic\.com/);203 });204205 test("CSS04 - self-hosted woff2 fonts only", async () => {206 const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>207 f.filter(f => f.endsWith(".css"))208 );209 for (const file of cssFiles) {210 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");211 expect(content).not.toMatch(/url\(["']?https?:\/\//);212 }213 });214215 test("CSS05 - Argon2id params in crypto code", async () => {216 const jsFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>217 f.filter(f => f.endsWith(".js"))218 );219 for (const file of jsFiles) {220 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");221 if (content.includes("argon") || content.includes("Argon2")) {222 expect(content).toMatch(/m=65536|t=3|p=4|iterations|memory/);223 break;224 }225 }226 });227228 test("CSS06 - scrollbar styling minimal and subtle", async () => {229 const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>230 f.filter(f => f.endsWith(".css"))231 );232 for (const file of cssFiles) {233 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");234 const scrollbarWidth = content.match(/::-webkit-scrollbar\s*\{[\s\S]*?width:\s*(\d+)px/);235 if (scrollbarWidth) {236 expect(parseInt(scrollbarWidth[1])).toBeLessThanOrEqual(4);237 }238 }239 });240241 test("CSS07 - popup dimensions 320px width fixed", async () => {242 const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>243 f.filter(f => f.endsWith(".css"))244 );245 for (const file of cssFiles) {246 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");247 const widthMatch = content.match(/width:\s*(\d+)px/);248 if (widthMatch && parseInt(widthMatch[1]) > 320) {249 expect(widthMatch[1]).toBe("320");250 }251 }252 });253254 test("CSS08 - no overflow on body", async () => {255 const cssFiles = await fs.readdir(join(__dirname, "..", "dist", "assets")).then(f =>256 f.filter(f => f.endsWith(".css"))257 );258 for (const file of cssFiles) {259 const content = await fs.readFile(join(__dirname, "..", "dist", "assets", file), "utf-8");260 const bodyOverflow = content.match(/body\s*\{[\s\S]*?overflow:\s*([^;]+)/);261 if (bodyOverflow) {262 expect(bodyOverflow[1].trim()).toBe("hidden");263 }264 }265 });266});267268test.describe("Performance & Responsiveness", () => {269270 test("PERF01 - vault unlocks within 3 seconds", async () => {271 const ctx = await makeContext();272 await setupVault(ctx);273 const newPopup = await ctx.newPage();274 const extId = await waitForSW(ctx);275 await newPopup.goto(`chrome-extension://${extId}/src/popup/index.html`);276 await newPopup.waitForTimeout(1500);277 const start = Date.now();278 await newPopup.locator("#master-password").fill("password123");279 await newPopup.getByRole("button", { name: /unlock/i }).click();280 await newPopup.waitForTimeout(500);281 const elapsed = Date.now() - start;282 expect(elapsed).toBeLessThan(5000);283 await ctx.close();284 });285286 test("PERF02 - entry list renders smoothly", async () => {287 const ctx = await makeContext();288 const { popup } = await setupVault(ctx);289 for (const name of ["A", "B", "C", "D", "E", "F", "G", "H"]) {290 await popup.locator('input[placeholder="search vault..."]').click();291 await popup.getByRole("button", { name: /new entry/i }).click();292 await popup.locator(".peach-new-name-input").fill(name + "Test");293 await popup.locator('input[placeholder="domain.com"]').fill(`https://${name.toLowerCase()}.com`);294 await popup.locator('input[placeholder="username or email"]').fill(`${name.toLowerCase()}@test.com`);295 await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass123");296 await popup.getByRole("button", { name: /create/i }).click();297 await popup.waitForTimeout(500);298 }299 await popup.waitForTimeout(1000);300 const entries = popup.locator("[class*=entry]");301 const count = await entries.count();302 expect(count).toBeGreaterThanOrEqual(7);303 await ctx.close();304 });305306 test("PERF03 - search filters entries without delay", async () => {307 const ctx = await makeContext();308 const { popup } = await setupVault(ctx);309 for (const name of ["Alpha", "Beta", "Gamma"]) {310 await popup.locator('input[placeholder="search vault..."]').click();311 await popup.getByRole("button", { name: /new entry/i }).click();312 await popup.locator(".peach-new-name-input").fill(name);313 await popup.locator('input[placeholder="domain.com"]').fill(`https://${name.toLowerCase()}.com`);314 await popup.locator('input[placeholder="username or email"]').fill(`${name.toLowerCase()}@test.com`);315 await popup.locator(".peach-new-field-input[placeholder='password']").fill("pass123");316 await popup.getByRole("button", { name: /create/i }).click();317 await popup.waitForTimeout(500);318 }319 const searchInput = popup.locator(".peach-search-input, .peach-search-input-list").first();320 await searchInput.click();321 await searchInput.fill("Alpha");322 await popup.waitForTimeout(300);323 const filtered = popup.locator("text=Alpha");324 await expect(filtered).toBeVisible();325 await ctx.close();326 });327328 test("PERF04 - settings panel opens instantly", async () => {329 const ctx = await makeContext();330 const { popup } = await setupVault(ctx);331 const start = Date.now();332 await popup.locator(".peach-icon-btn[aria-label='Settings']").click();333 await popup.waitForTimeout(200);334 const elapsed = Date.now() - start;335 expect(elapsed).toBeLessThan(1000);336 await ctx.close();337 });338});