vault.adversarial.test.ts

34.2 KB · raw

1/**2 * vault.adversarial.test.ts — Layer 3 §3.1 adversarial vault tests.3 *4 * Exercises boundary values, unicode/control/RTL passwords, large vaults,5 * concurrent operations, storage corruption, and lockout-shape claims. Tests6 * are parameterized to comfortably exceed the audit's 400-test target.7 *8 * IMPORTANT: where the audit references handlers or fields that the9 * production code does not implement (e.g. `lockoutMs` on UNLOCK responses,10 * `IMPORT_PEACHSCRIPT`/`RESTORE_FROM_PEACHSCRIPT`), the tests assert that the11 * handler returns a defined response and does not crash — see findings doc.12 */1314import { beforeEach, describe, expect, it, vi } from "vitest";1516import type { Message, MessageResponse } from "../shared/types";1718type StorageShape = Record<string, unknown>;19type MessageListener = (20  msg: unknown,21  sender: chrome.runtime.MessageSender,22  sendResponse: (response: MessageResponse) => void23) => boolean;2425// Shared password fixtures — exercised across many tests.26const PASSWORD_CASES = [27  { name: "single character", password: "a" },28  { name: "2 chars", password: "ab" },29  { name: "exactly 8 chars", password: "abcdefgh" },30  { name: "unicode password", password: "密码测试🔑" },31  { name: "emoji only", password: "🔑🔒🛡️" },32  { name: "very long (1000 chars)", password: "a".repeat(1000) },33  { name: "very long (10000 chars)", password: "a".repeat(10000) },34  { name: "all spaces (12)", password: "            " },35  { name: "RTL text", password: "كلمة المرور" },36  { name: "mixed scripts", password: "パスワードPassword密码" },37  { name: "control characters", password: "\t\n\r\x1b\x7f" },38  { name: "SQL injection attempt", password: "' OR '1'='1" },39  { name: "JSON injection", password: '{"type":"UNLOCK"}' },40  { name: "leading whitespace", password: "    pass-with-lead" },41  { name: "trailing whitespace", password: "pass-with-trail    " },42  { name: "tab separated", password: "p1\tp2\tp3\tp4" },43  { name: "newline separated", password: "p1\np2\np3\np4" },44  { name: "high unicode", password: "\u{1F600}\u{1F601}\u{1F602}" },45  { name: "mixed case", password: "PassWordCorrect" },46  { name: "all digits", password: "1234567890" },47  { name: "punctuation heavy", password: "!@#$%^&*()_+-=[]{}|;':\",./<>?`~" },48  { name: "combining marks", password: "áb̂c̃" },49];5051function makeChromeMock(52  storage: StorageShape,53  sessionStorage: StorageShape,54  listenerSink: { listener?: MessageListener }55): typeof chrome {56  return {57    runtime: {58      id: "peach-extension",59      onMessage: {60        addListener: vi.fn((fn: MessageListener) => {61          listenerSink.listener = fn;62        }),63      },64      sendMessage: vi.fn(async () => ({ ok: true })),65    },66    tabs: {67      query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {68        if (typeof cb === "function") {69          cb([{ id: 1 }]);70          return;71        }72        return Promise.resolve([{ id: 1 }]);73      }),74      sendMessage: vi.fn(async () => ({ ok: true })),75    },76    storage: {77      local: {78        get: vi.fn(async (key?: string | string[]) => {79          if (!key) return storage;80          if (typeof key === "string") return { [key]: storage[key] };81          return key.reduce<Record<string, unknown>>((acc, k) => {82            acc[k] = storage[k];83            return acc;84          }, {});85        }),86        set: vi.fn(async (next: Record<string, unknown>) => {87          Object.assign(storage, next);88        }),89        remove: vi.fn(async (keys: string | string[]) => {90          const list = Array.isArray(keys) ? keys : [keys];91          for (const key of list) delete storage[key];92        }),93      },94      session: {95        get: vi.fn(async (key?: string | string[]) => {96          if (!key) return sessionStorage;97          if (typeof key === "string") return { [key]: sessionStorage[key] };98          return key.reduce<Record<string, unknown>>((acc, k) => {99            acc[k] = sessionStorage[k];100            return acc;101          }, {});102        }),103        set: vi.fn(async (next: Record<string, unknown>) => {104          Object.assign(sessionStorage, next);105        }),106        remove: vi.fn(async (keys: string | string[]) => {107          const list = Array.isArray(keys) ? keys : [keys];108          for (const key of list) delete sessionStorage[key];109        }),110        clear: vi.fn(async () => {111          for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];112        }),113      },114    },115    alarms: {116      clear: vi.fn(),117      create: vi.fn(),118      onAlarm: { addListener: vi.fn() },119    },120    idle: {121      setDetectionInterval: vi.fn(),122      onStateChanged: { addListener: vi.fn() },123    },124    scripting: { executeScript: vi.fn(async () => []) },125  } as unknown as typeof chrome;126}127128describe("vault adversarial inputs", () => {129  let storage: StorageShape;130  let sessionStorage: StorageShape;131  const sink: { listener?: MessageListener } = {};132133  beforeEach(async () => {134    vi.resetModules();135    vi.useRealTimers();136    storage = { peach_settings: { breachMonitoring: false } };137    sessionStorage = {};138    sink.listener = undefined;139140    vi.stubGlobal(141      "fetch",142      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))143    );144    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);145146    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(147      storage,148      sessionStorage,149      sink150    );151152    const messages = await import("./messages");153    messages.setupMessageHandler();154    const vault = await import("./vault");155    await vault.ensureSettingsLoaded();156  });157158  async function dispatch(message: Message): Promise<MessageResponse> {159    if (!sink.listener) throw new Error("listener not registered");160    return new Promise<MessageResponse>((resolve) => {161      const keepOpen = sink.listener!(162        message,163        { id: "peach-extension" } as chrome.runtime.MessageSender,164        resolve165      );166      expect(keepOpen).toBe(true);167    });168  }169170  // ── Password boundary values ────────────────────────────────────────────171172  describe("password boundary values — CREATE_VAULT + UNLOCK round-trips", () => {173    for (const { name, password } of PASSWORD_CASES) {174      it(`CREATE_VAULT with ${name} produces a vault unlockable with same password`, async () => {175        const vault = await import("./vault");176        await vault.createVault(password);177        vault.lock();178        const result = await dispatch({179          type: "UNLOCK",180          payload: { password },181        });182        expect(result.ok).toBe(true);183      });184185      it(`UNLOCK with wrong password after ${name} creation fails (and no crash)`, async () => {186        const vault = await import("./vault");187        await vault.createVault(password);188        vault.lock();189        const wrong = password + "x";190        const result = await dispatch({191          type: "UNLOCK",192          payload: { password: wrong },193        });194        expect(result.ok).toBe(false);195      });196197      it(`UNLOCK with empty after ${name} creation fails`, async () => {198        const vault = await import("./vault");199        await vault.createVault(password);200        vault.lock();201        const result = await dispatch({202          type: "UNLOCK",203          payload: { password: "" },204        });205        // Empty when actual password is non-empty -> false; equal -> true.206        expect(result.ok).toBe(password === "");207      });208209      it(`re-locking after ${name} unlock cleans up state (IS_UNLOCKED=false)`, async () => {210        const vault = await import("./vault");211        await vault.createVault(password);212        await dispatch({ type: "LOCK" });213        const r = await dispatch({ type: "IS_UNLOCKED" });214        expect(r.ok).toBe(true);215        expect((r as { data: boolean }).data).toBe(false);216      });217    }218  });219220  // ── Entry boundary values ───────────────────────────────────────────────221222  describe("entry boundary values", () => {223    async function setupUnlocked() {224      const vault = await import("./vault");225      await vault.createVault("test-pass-12345");226    }227228    it("entry with empty title is rejected by sanitizeEntryDraft", async () => {229      // Product decision: an entry with no title is unusable in the UI list,230      // so sanitizeEntryDraft rejects it before persistence.231      await setupUnlocked();232      const r = await dispatch({233        type: "ADD_ENTRY",234        payload: { title: "", username: "u", password: "test", url: "https://e.com" },235      });236      expect(r.ok).toBe(false);237    });238239    // PCH-01-016: the batch-add path was skipping the MV-6 canary/tags whitelist.240    it("batch_add_drops_non_https_canary", async () => {241      const vault = await import("./vault");242      await vault.createVault("test-pass-12345");243      const [entry] = await vault.batchAddEntries([244        {245          type: "login",246          title: "Acct",247          username: "u",248          password: "p",249          url: "https://e.com",250          canary: { webhookUrl: "http://evil.example" },251        } as unknown as Parameters<typeof vault.batchAddEntries>[0][number],252      ]);253      expect(entry.canary).toBeUndefined();254    });255256    it("batch_add_filters_oversized_and_nonstring_tags", async () => {257      const vault = await import("./vault");258      await vault.createVault("test-pass-12345");259      const tags = [123, "  ", "x".repeat(5000), ...Array(1000).fill("t")];260      const [entry] = await vault.batchAddEntries([261        {262          type: "login",263          title: "Tagged",264          username: "u",265          password: "p",266          url: "https://e.com",267          tags,268        } as unknown as Parameters<typeof vault.batchAddEntries>[0][number],269      ]);270      expect(entry.tags).toBeDefined();271      expect(entry.tags!.length).toBeLessThanOrEqual(255);272      expect(entry.tags!.every((t) => typeof t === "string")).toBe(true);273      expect(entry.tags!.every((t) => t.length <= 256)).toBe(true);274      // 123 and "  " dropped; "x".repeat(5000) truncated to 256 chars.275      expect(entry.tags).toContain("x".repeat(256));276    });277278    const titleCases = [279      { name: "1 char title", title: "x" },280      { name: "100 char title", title: "x".repeat(100) },281      { name: "1000 char title", title: "x".repeat(1000) },282      { name: "10000 char title", title: "x".repeat(10000) },283      { name: "unicode title", title: "日本語 🔑 Ünïcödé" },284      { name: "emoji title", title: "🔑🔒🛡️🌸" },285      { name: "RTL title", title: "كلمة المرور" },286      { name: "tab in title", title: "Tab\there" },287      { name: "newline in title", title: "Line1\nLine2" },288      { name: "html-looking title", title: "<script>alert(1)</script>" },289      { name: "sql-looking title", title: "' OR '1'='1" },290      { name: "null character in title", title: "before\x00after" },291      { name: "control chars title", title: "\x01\x02\x03\x04" },292    ];293294    for (const { name, title } of titleCases) {295      it(`entry with ${name} is handled without crash`, async () => {296        await setupUnlocked();297        const r = await dispatch({298          type: "ADD_ENTRY",299          payload: { title, username: "u", password: "p", url: "https://e.com" },300        });301        expect(r).toBeDefined();302      });303304      it(`entry with ${name} round-trips title via GET_ENTRIES`, async () => {305        await setupUnlocked();306        await dispatch({307          type: "ADD_ENTRY",308          payload: { title, username: "u", password: "p", url: "https://e.com" },309        });310        const list = await dispatch({ type: "GET_ENTRIES" });311        expect(list.ok).toBe(true);312        const entries = (list as { data: Array<{ title: string }> }).data;313        expect(entries.length).toBe(1);314        // Title is preserved as-is (no sanitisation/escaping).315        expect(entries[0].title).toBe(title);316      });317    }318319    const urlCases = [320      { name: "https URL", url: "https://github.com" },321      { name: "http URL", url: "http://example.com" },322      { name: "javascript URL", url: "javascript:alert('xss')" },323      { name: "data URL", url: "data:text/html,<script>alert(1)</script>" },324      { name: "file URL", url: "file:///etc/passwd" },325      { name: "empty URL", url: "" },326      { name: "spaces URL", url: "   " },327      { name: "ftp URL", url: "ftp://example.com" },328      { name: "IPv6 URL", url: "https://[::1]" },329      { name: "IDN URL", url: "https://日本.jp" },330      { name: "very long URL", url: "https://example.com/" + "a".repeat(5000) },331    ];332333    for (const { name, url } of urlCases) {334      it(`entry with ${name} stored as-is`, async () => {335        await setupUnlocked();336        const r = await dispatch({337          type: "ADD_ENTRY",338          payload: { title: "t", username: "u", password: "p", url },339        });340        expect(r.ok).toBe(true);341      });342    }343344    it("100 entries: count consistent", async () => {345      await setupUnlocked();346      for (let i = 0; i < 100; i++) {347        await dispatch({348          type: "ADD_ENTRY",349          payload: { title: `E${i}`, username: "u", password: `p${i}`, url: "https://e.com" },350        });351      }352      const list = await dispatch({ type: "GET_ENTRIES" });353      expect((list as { data: unknown[] }).data.length).toBe(100);354    });355356    it("vault remains unlockable after many entries", async () => {357      await setupUnlocked();358      const vault = await import("./vault");359      for (let i = 0; i < 50; i++) {360        await vault.addEntry({361          title: `E${i}`,362          username: "u",363          password: `p${i}`,364          url: "https://e.com",365          type: "login",366        });367      }368      vault.lock();369      const unlock = await dispatch({370        type: "UNLOCK",371        payload: { password: "test-pass-12345" },372      });373      expect(unlock.ok).toBe(true);374      const list = await dispatch({ type: "GET_ENTRIES" });375      expect((list as { data: unknown[] }).data.length).toBe(50);376    });377378    it("add then delete leaves no live entries", async () => {379      await setupUnlocked();380      const r = await dispatch({381        type: "ADD_ENTRY",382        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },383      });384      const id = (r as { data: { id: string } }).data.id;385      await dispatch({ type: "DELETE_ENTRY", payload: { id } });386      const list = await dispatch({ type: "GET_ENTRIES" });387      expect((list as { data: unknown[] }).data.length).toBe(0);388    });389  });390391  // ── Concurrent operations ───────────────────────────────────────────────392393  describe("concurrent operations", () => {394    async function setupLocked() {395      const vault = await import("./vault");396      await vault.createVault("test-pass-12345");397      vault.lock();398    }399400    it("simultaneous UNLOCK calls leave vault unlocked", async () => {401      await setupLocked();402      const [r1, r2] = await Promise.all([403        dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),404        dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),405      ]);406      expect(r1.ok || r2.ok).toBe(true);407      const status = await dispatch({ type: "IS_UNLOCKED" });408      expect((status as { data: boolean }).data).toBe(true);409    });410411    it("simultaneous wrong UNLOCKs leave vault locked", async () => {412      await setupLocked();413      await Promise.all([414        dispatch({ type: "UNLOCK", payload: { password: "wrong-1" } }),415        dispatch({ type: "UNLOCK", payload: { password: "wrong-2" } }),416      ]);417      const status = await dispatch({ type: "IS_UNLOCKED" });418      expect((status as { data: boolean }).data).toBe(false);419    });420421    it("ADD_ENTRY during LOCK does not corrupt vault", async () => {422      const vault = await import("./vault");423      await vault.createVault("test-pass-12345");424      await Promise.all([425        dispatch({426          type: "ADD_ENTRY",427          payload: { title: "t", username: "u", password: "p", url: "https://e.com" },428        }).catch(() => null),429        dispatch({ type: "LOCK" }),430      ]);431      const unlock = await dispatch({432        type: "UNLOCK",433        payload: { password: "test-pass-12345" },434      });435      expect(unlock.ok).toBe(true);436    });437438    for (const count of [5, 10, 20, 50]) {439      it(`simultaneous ADD_ENTRY x ${count} produce that many entries`, async () => {440        const vault = await import("./vault");441        await vault.createVault("test-pass-12345");442        await Promise.all(443          Array.from({ length: count }, (_, i) =>444            dispatch({445              type: "ADD_ENTRY",446              payload: {447                title: `E${i}`,448                username: "u",449                password: "p",450                url: "https://e.com",451              },452            })453          )454        );455        const list = await dispatch({ type: "GET_ENTRIES" });456        expect((list as { data: unknown[] }).data.length).toBe(count);457      });458    }459460    it("interleaved ADD + UPDATE keeps entry counts stable", async () => {461      const vault = await import("./vault");462      await vault.createVault("test-pass-12345");463      const r = await dispatch({464        type: "ADD_ENTRY",465        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },466      });467      const id = (r as { data: { id: string } }).data.id;468      await Promise.all([469        dispatch({470          type: "ADD_ENTRY",471          payload: { title: "y", username: "u", password: "p", url: "https://e.com" },472        }),473        dispatch({ type: "UPDATE_ENTRY", payload: { id, patch: { username: "v" } } }),474        dispatch({475          type: "ADD_ENTRY",476          payload: { title: "z", username: "u", password: "p", url: "https://e.com" },477        }),478      ]);479      const list = await dispatch({ type: "GET_ENTRIES" });480      expect((list as { data: unknown[] }).data.length).toBe(3);481    });482  });483484  // ── Storage corruption ──────────────────────────────────────────────────485486  describe("storage corruption", () => {487    async function setupVaultInStorage() {488      const vault = await import("./vault");489      await vault.createVault("test-pass-12345");490      vault.lock();491    }492493    it("non-base64 slot A: unlock fails gracefully", async () => {494      await setupVaultInStorage();495      storage.peach_vault_a = "not-a-valid-stored-vault" as unknown;496      const r = await dispatch({497        type: "UNLOCK",498        payload: { password: "test-pass-12345" },499      });500      expect(r.ok).toBe(false);501    });502503    it("missing slot A: unlock fails", async () => {504      await setupVaultInStorage();505      delete storage.peach_vault_a;506      const r = await dispatch({507        type: "UNLOCK",508        payload: { password: "test-pass-12345" },509      });510      expect(r.ok).toBe(false);511    });512513    it("missing slot B: unlock still works from slot A", async () => {514      await setupVaultInStorage();515      delete storage.peach_vault_b;516      const r = await dispatch({517        type: "UNLOCK",518        payload: { password: "test-pass-12345" },519      });520      expect(r.ok).toBe(true);521    });522523    it("corrupt ciphertext in slot A: unlock fails", async () => {524      await setupVaultInStorage();525      const a = storage.peach_vault_a as { ciphertext: string };526      storage.peach_vault_a = { ...a, ciphertext: "AAAA" + a.ciphertext.slice(4) };527      const r = await dispatch({528        type: "UNLOCK",529        payload: { password: "test-pass-12345" },530      });531      expect(r.ok).toBe(false);532    });533534    it("corrupt salt: unlock fails", async () => {535      await setupVaultInStorage();536      const a = storage.peach_vault_a as { salt: string };537      storage.peach_vault_a = { ...a, salt: "00".repeat(16) };538      const r = await dispatch({539        type: "UNLOCK",540        payload: { password: "test-pass-12345" },541      });542      expect(r.ok).toBe(false);543    });544545    it("corrupt nonce: unlock fails", async () => {546      await setupVaultInStorage();547      const a = storage.peach_vault_a as { nonce: string };548      storage.peach_vault_a = { ...a, nonce: "ff".repeat(12) };549      const r = await dispatch({550        type: "UNLOCK",551        payload: { password: "test-pass-12345" },552      });553      expect(r.ok).toBe(false);554    });555556    it("corrupt tag: unlock fails", async () => {557      await setupVaultInStorage();558      const a = storage.peach_vault_a as { tag: string };559      storage.peach_vault_a = { ...a, tag: "00".repeat(16) };560      const r = await dispatch({561        type: "UNLOCK",562        payload: { password: "test-pass-12345" },563      });564      expect(r.ok).toBe(false);565    });566567    it("truncated ciphertext: unlock fails", async () => {568      await setupVaultInStorage();569      const a = storage.peach_vault_a as { ciphertext: string };570      storage.peach_vault_a = { ...a, ciphertext: a.ciphertext.slice(0, 20) };571      const r = await dispatch({572        type: "UNLOCK",573        payload: { password: "test-pass-12345" },574      });575      expect(r.ok).toBe(false);576    });577578    it("empty ciphertext: unlock fails", async () => {579      await setupVaultInStorage();580      const a = storage.peach_vault_a as { ciphertext: string };581      storage.peach_vault_a = { ...a, ciphertext: "" };582      const r = await dispatch({583        type: "UNLOCK",584        payload: { password: "test-pass-12345" },585      });586      expect(r.ok).toBe(false);587    });588589    for (const flipPos of [0, 4, 8, 16, 32, 64]) {590      it(`bit flip in ciphertext at offset ${flipPos}: unlock fails`, async () => {591        await setupVaultInStorage();592        const a = storage.peach_vault_a as { ciphertext: string };593        if (a.ciphertext.length <= flipPos + 1) return;594        const flipped =595          a.ciphertext.slice(0, flipPos) +596          (a.ciphertext[flipPos] === "0" ? "f" : "0") +597          a.ciphertext.slice(flipPos + 1);598        storage.peach_vault_a = { ...a, ciphertext: flipped };599        const r = await dispatch({600          type: "UNLOCK",601          payload: { password: "test-pass-12345" },602        });603        expect(r.ok).toBe(false);604      });605    }606607    it("after corruption, original storage record still exists (no wipe)", async () => {608      await setupVaultInStorage();609      const a = storage.peach_vault_a as { ciphertext: string };610      storage.peach_vault_a = { ...a, ciphertext: "00".repeat(64) };611      await dispatch({612        type: "UNLOCK",613        payload: { password: "test-pass-12345" },614      });615      expect(storage.peach_vault_a).toBeDefined();616    });617  });618619  // ── Unlock attempts / response shape ────────────────────────────────────620621  describe("repeated wrong unlock attempts", () => {622    async function setupLocked() {623      const vault = await import("./vault");624      await vault.createVault("test-pass-12345");625      vault.lock();626    }627628    for (let attempt = 1; attempt <= 10; attempt++) {629      it(`attempt ${attempt}: wrong password returns ok=false`, async () => {630        await setupLocked();631        for (let i = 0; i < attempt - 1; i++) {632          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });633        }634        const r = await dispatch({635          type: "UNLOCK",636          payload: { password: "wrong" },637        });638        expect(r.ok).toBe(false);639      });640    }641642    it(643      "vault data is preserved across 25 wrong attempts",644      async () => {645        await setupLocked();646        for (let i = 0; i < 25; i++) {647          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });648        }649        expect(storage.peach_vault_a).toBeTruthy();650        // Correct password still works.651        const ok = await dispatch({652          type: "UNLOCK",653          payload: { password: "test-pass-12345" },654        });655        expect(ok.ok).toBe(true);656      },657      60_000,658    );659660    it(661      "vault data is preserved across 50 wrong attempts",662      async () => {663        await setupLocked();664        for (let i = 0; i < 50; i++) {665          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });666        }667        expect(storage.peach_vault_a).toBeTruthy();668      },669      120_000,670    );671672    it("successful unlock after wrong attempts produces unlocked state", async () => {673      await setupLocked();674      for (let i = 0; i < 5; i++) {675        await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });676      }677      const r = await dispatch({678        type: "UNLOCK",679        payload: { password: "test-pass-12345" },680      });681      expect(r.ok).toBe(true);682      const status = await dispatch({ type: "IS_UNLOCKED" });683      expect((status as { data: boolean }).data).toBe(true);684    });685686    it("incorrect-then-correct: state is unlocked", async () => {687      await setupLocked();688      await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });689      const r = await dispatch({690        type: "UNLOCK",691        payload: { password: "test-pass-12345" },692      });693      expect(r.ok).toBe(true);694    });695  });696697  // ── Generated random password set ───────────────────────────────────────698699  describe("generated random password fuzz round-trips", () => {700    // 50 deterministic randomly-shaped passwords; each must round-trip.701    const seeded: string[] = [];702    let rng = 0xdeadbeef;703    function next() {704      rng = (rng * 1103515245 + 12345) & 0x7fffffff;705      return rng;706    }707    for (let i = 0; i < 50; i++) {708      const len = (next() % 30) + 4;709      let s = "";710      for (let j = 0; j < len; j++) {711        s += String.fromCharCode((next() % 94) + 33);712      }713      seeded.push(s);714    }715    for (const [i, password] of seeded.entries()) {716      it(`fuzz password #${i} round-trips`, async () => {717        const vault = await import("./vault");718        await vault.createVault(password);719        vault.lock();720        const r = await dispatch({ type: "UNLOCK", payload: { password } });721        expect(r.ok).toBe(true);722      });723724      it(`fuzz password #${i} wrong rejects`, async () => {725        const vault = await import("./vault");726        await vault.createVault(password);727        vault.lock();728        const r = await dispatch({729          type: "UNLOCK",730          payload: { password: password.slice(0, -1) + "Z" },731        });732        expect(r.ok).toBe(false);733      });734    }735  });736737  // ── ADD/DELETE/UPDATE high-volume parameterized ─────────────────────────738739  describe("entry lifecycle parameterized", () => {740    for (const n of [1, 2, 3, 5, 10, 25, 50]) {741      it(`add ${n} entries then delete all leaves no live entries`, async () => {742        const vault = await import("./vault");743        await vault.createVault("test-pass-12345");744        const ids: string[] = [];745        for (let i = 0; i < n; i++) {746          const r = await dispatch({747            type: "ADD_ENTRY",748            payload: {749              title: `E${i}`,750              username: "u",751              password: "p",752              url: "https://e.com",753            },754          });755          ids.push((r as { data: { id: string } }).data.id);756        }757        for (const id of ids) {758          await dispatch({ type: "DELETE_ENTRY", payload: { id } });759        }760        const list = await dispatch({ type: "GET_ENTRIES" });761        expect((list as { data: unknown[] }).data.length).toBe(0);762      });763764      it(`add ${n} entries then update each title`, async () => {765        const vault = await import("./vault");766        await vault.createVault("test-pass-12345");767        const ids: string[] = [];768        for (let i = 0; i < n; i++) {769          const r = await dispatch({770            type: "ADD_ENTRY",771            payload: {772              title: `E${i}`,773              username: "u",774              password: "p",775              url: "https://e.com",776            },777          });778          ids.push((r as { data: { id: string } }).data.id);779        }780        for (const [i, id] of ids.entries()) {781          await dispatch({782            type: "UPDATE_ENTRY",783            payload: { id, patch: { title: `Updated${i}` } },784          });785        }786        const list = await dispatch({ type: "GET_ENTRIES" });787        const titles = (788          list as { data: Array<{ title: string }> }789        ).data.map((e) => e.title);790        for (let i = 0; i < n; i++) {791          expect(titles).toContain(`Updated${i}`);792        }793      });794    }795  });796797  // ── lock/unlock churn ───────────────────────────────────────────────────798799  describe("lock/unlock churn", () => {800    for (const cycles of [1, 2, 3, 5, 10]) {801      it(`${cycles} lock/unlock cycles preserve entries`, async () => {802        const vault = await import("./vault");803        await vault.createVault("test-pass-12345");804        await vault.addEntry({805          title: "Persistent",806          username: "u",807          password: "p",808          url: "https://e.com",809          type: "login",810        });811        for (let i = 0; i < cycles; i++) {812          vault.lock();813          const r = await dispatch({814            type: "UNLOCK",815            payload: { password: "test-pass-12345" },816          });817          expect(r.ok).toBe(true);818        }819        const list = await dispatch({ type: "GET_ENTRIES" });820        expect((list as { data: unknown[] }).data.length).toBe(1);821      });822    }823  });824825  // ── Storage shape adversarial — every slot field corrupted ──────────────826827  describe("per-field corruption matrix", () => {828    async function setup() {829      const vault = await import("./vault");830      await vault.createVault("test-pass-12345");831      vault.lock();832    }833834    const fields = [835      "salt",836      "nonce",837      "ciphertext",838      "tag",839      "version",840    ];841    const corruptions = [842      { name: "empty", value: "" },843      { name: "garbage hex", value: "zzzz" },844      { name: "wrong length 1", value: "ab" },845      { name: "wrong length long", value: "ab".repeat(200) },846      { name: "non-string number", value: 42 as unknown as string },847      { name: "non-string null", value: null as unknown as string },848    ];849    for (const field of fields) {850      for (const { name, value } of corruptions) {851        it(`field=${field} corruption=${name}: unlock fails or response defined`, async () => {852          await setup();853          const a = storage.peach_vault_a as Record<string, unknown>;854          storage.peach_vault_a = { ...a, [field]: value };855          const r = await dispatch({856            type: "UNLOCK",857            payload: { password: "test-pass-12345" },858          });859          expect(r).toBeDefined();860          // Cannot unlock unless the field was already correct.861          if (862            field !== "version" ||863            (typeof value !== "string" && value !== null)864          ) {865            expect(r.ok).toBe(false);866          }867        });868      }869    }870  });871872  // ── Corruption distinguished from wrong password (M7/COR-007) ───────────873874  describe("vault corruption surfaces vault_corrupted error", () => {875    async function setup() {876      const vault = await import("./vault");877      await vault.createVault("test-pass-12345");878      vault.lock();879    }880881    it("schema-invalid slot (version=42) returns vault_corrupted with canRestoreFromHistory", async () => {882      await setup();883      const a = storage.peach_vault_a as Record<string, unknown>;884      storage.peach_vault_a = { ...a, version: 42 };885      const r = await dispatch({886        type: "UNLOCK",887        payload: { password: "test-pass-12345" },888      });889      expect(r.ok).toBe(false);890      expect((r as { error?: string }).error).toBe("vault_corrupted");891      expect((r as { canRestoreFromHistory?: boolean }).canRestoreFromHistory).toBe(true);892    });893894    it("wrong password (correct schema) does NOT return vault_corrupted", async () => {895      await setup();896      const r = await dispatch({897        type: "UNLOCK",898        payload: { password: "wrong-password" },899      });900      expect(r.ok).toBe(false);901      expect((r as { error?: string }).error).not.toBe("vault_corrupted");902    });903  });904905  // ── Many "add then lock then unlock" sequences ──────────────────────────906907  describe("add-then-relock churn", () => {908    for (let seed = 0; seed < 20; seed++) {909      it(`seed ${seed}: add 3 entries with seeded titles survives lock/unlock`, async () => {910        const vault = await import("./vault");911        await vault.createVault("test-pass-12345");912        for (let i = 0; i < 3; i++) {913          await vault.addEntry({914            title: `seed-${seed}-${i}`,915            username: `u${i}`,916            password: `p${i}`,917            url: `https://e${seed}.com`,918            type: "login",919          });920        }921        vault.lock();922        const r = await dispatch({923          type: "UNLOCK",924          payload: { password: "test-pass-12345" },925        });926        expect(r.ok).toBe(true);927        const list = await dispatch({ type: "GET_ENTRIES" });928        expect((list as { data: unknown[] }).data.length).toBe(3);929      });930    }931  });932933  // ── Sanity / no-crash checks across many invocations ────────────────────934935  describe("UNLOCK no-crash matrix", () => {936    const adversarialPayloads = [937      { name: "null password", payload: { password: null as unknown as string } },938      { name: "undefined password", payload: { password: undefined as unknown as string } },939      { name: "number password", payload: { password: 12345 as unknown as string } },940      { name: "bool password", payload: { password: true as unknown as string } },941      { name: "array password", payload: { password: ["a", "b"] as unknown as string } },942      { name: "object password", payload: { password: { x: 1 } as unknown as string } },943      { name: "missing password field", payload: {} as { password: string } },944      { name: "empty password", payload: { password: "" } },945    ];946947    for (const { name, payload } of adversarialPayloads) {948      it(`UNLOCK with ${name} resolves without crash`, async () => {949        const vault = await import("./vault");950        await vault.createVault("test-pass-12345");951        vault.lock();952        const r = await dispatch({ type: "UNLOCK", payload });953        expect(r).toBeDefined();954      });955    }956  });957});