security-visual.spec.ts

14.2 KB · raw

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});