vault.adversarial.test.ts

32.7 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    const titleCases = [240      { name: "1 char title", title: "x" },241      { name: "100 char title", title: "x".repeat(100) },242      { name: "1000 char title", title: "x".repeat(1000) },243      { name: "10000 char title", title: "x".repeat(10000) },244      { name: "unicode title", title: "日本語 🔑 Ünïcödé" },245      { name: "emoji title", title: "🔑🔒🛡️🌸" },246      { name: "RTL title", title: "كلمة المرور" },247      { name: "tab in title", title: "Tab\there" },248      { name: "newline in title", title: "Line1\nLine2" },249      { name: "html-looking title", title: "<script>alert(1)</script>" },250      { name: "sql-looking title", title: "' OR '1'='1" },251      { name: "null character in title", title: "before\x00after" },252      { name: "control chars title", title: "\x01\x02\x03\x04" },253    ];254255    for (const { name, title } of titleCases) {256      it(`entry with ${name} is handled without crash`, async () => {257        await setupUnlocked();258        const r = await dispatch({259          type: "ADD_ENTRY",260          payload: { title, username: "u", password: "p", url: "https://e.com" },261        });262        expect(r).toBeDefined();263      });264265      it(`entry with ${name} round-trips title via GET_ENTRIES`, async () => {266        await setupUnlocked();267        await dispatch({268          type: "ADD_ENTRY",269          payload: { title, username: "u", password: "p", url: "https://e.com" },270        });271        const list = await dispatch({ type: "GET_ENTRIES" });272        expect(list.ok).toBe(true);273        const entries = (list as { data: Array<{ title: string }> }).data;274        expect(entries.length).toBe(1);275        // Title is preserved as-is (no sanitisation/escaping).276        expect(entries[0].title).toBe(title);277      });278    }279280    const urlCases = [281      { name: "https URL", url: "https://github.com" },282      { name: "http URL", url: "http://example.com" },283      { name: "javascript URL", url: "javascript:alert('xss')" },284      { name: "data URL", url: "data:text/html,<script>alert(1)</script>" },285      { name: "file URL", url: "file:///etc/passwd" },286      { name: "empty URL", url: "" },287      { name: "spaces URL", url: "   " },288      { name: "ftp URL", url: "ftp://example.com" },289      { name: "IPv6 URL", url: "https://[::1]" },290      { name: "IDN URL", url: "https://日本.jp" },291      { name: "very long URL", url: "https://example.com/" + "a".repeat(5000) },292    ];293294    for (const { name, url } of urlCases) {295      it(`entry with ${name} stored as-is`, async () => {296        await setupUnlocked();297        const r = await dispatch({298          type: "ADD_ENTRY",299          payload: { title: "t", username: "u", password: "p", url },300        });301        expect(r.ok).toBe(true);302      });303    }304305    it("100 entries: count consistent", async () => {306      await setupUnlocked();307      for (let i = 0; i < 100; i++) {308        await dispatch({309          type: "ADD_ENTRY",310          payload: { title: `E${i}`, username: "u", password: `p${i}`, url: "https://e.com" },311        });312      }313      const list = await dispatch({ type: "GET_ENTRIES" });314      expect((list as { data: unknown[] }).data.length).toBe(100);315    });316317    it("vault remains unlockable after many entries", async () => {318      await setupUnlocked();319      const vault = await import("./vault");320      for (let i = 0; i < 50; i++) {321        await vault.addEntry({322          title: `E${i}`,323          username: "u",324          password: `p${i}`,325          url: "https://e.com",326          type: "login",327        });328      }329      vault.lock();330      const unlock = await dispatch({331        type: "UNLOCK",332        payload: { password: "test-pass-12345" },333      });334      expect(unlock.ok).toBe(true);335      const list = await dispatch({ type: "GET_ENTRIES" });336      expect((list as { data: unknown[] }).data.length).toBe(50);337    });338339    it("add then delete leaves no live entries", async () => {340      await setupUnlocked();341      const r = await dispatch({342        type: "ADD_ENTRY",343        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },344      });345      const id = (r as { data: { id: string } }).data.id;346      await dispatch({ type: "DELETE_ENTRY", payload: { id } });347      const list = await dispatch({ type: "GET_ENTRIES" });348      expect((list as { data: unknown[] }).data.length).toBe(0);349    });350  });351352  // ── Concurrent operations ───────────────────────────────────────────────353354  describe("concurrent operations", () => {355    async function setupLocked() {356      const vault = await import("./vault");357      await vault.createVault("test-pass-12345");358      vault.lock();359    }360361    it("simultaneous UNLOCK calls leave vault unlocked", async () => {362      await setupLocked();363      const [r1, r2] = await Promise.all([364        dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),365        dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),366      ]);367      expect(r1.ok || r2.ok).toBe(true);368      const status = await dispatch({ type: "IS_UNLOCKED" });369      expect((status as { data: boolean }).data).toBe(true);370    });371372    it("simultaneous wrong UNLOCKs leave vault locked", async () => {373      await setupLocked();374      await Promise.all([375        dispatch({ type: "UNLOCK", payload: { password: "wrong-1" } }),376        dispatch({ type: "UNLOCK", payload: { password: "wrong-2" } }),377      ]);378      const status = await dispatch({ type: "IS_UNLOCKED" });379      expect((status as { data: boolean }).data).toBe(false);380    });381382    it("ADD_ENTRY during LOCK does not corrupt vault", async () => {383      const vault = await import("./vault");384      await vault.createVault("test-pass-12345");385      await Promise.all([386        dispatch({387          type: "ADD_ENTRY",388          payload: { title: "t", username: "u", password: "p", url: "https://e.com" },389        }).catch(() => null),390        dispatch({ type: "LOCK" }),391      ]);392      const unlock = await dispatch({393        type: "UNLOCK",394        payload: { password: "test-pass-12345" },395      });396      expect(unlock.ok).toBe(true);397    });398399    for (const count of [5, 10, 20, 50]) {400      it(`simultaneous ADD_ENTRY x ${count} produce that many entries`, async () => {401        const vault = await import("./vault");402        await vault.createVault("test-pass-12345");403        await Promise.all(404          Array.from({ length: count }, (_, i) =>405            dispatch({406              type: "ADD_ENTRY",407              payload: {408                title: `E${i}`,409                username: "u",410                password: "p",411                url: "https://e.com",412              },413            })414          )415        );416        const list = await dispatch({ type: "GET_ENTRIES" });417        expect((list as { data: unknown[] }).data.length).toBe(count);418      });419    }420421    it("interleaved ADD + UPDATE keeps entry counts stable", async () => {422      const vault = await import("./vault");423      await vault.createVault("test-pass-12345");424      const r = await dispatch({425        type: "ADD_ENTRY",426        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },427      });428      const id = (r as { data: { id: string } }).data.id;429      await Promise.all([430        dispatch({431          type: "ADD_ENTRY",432          payload: { title: "y", username: "u", password: "p", url: "https://e.com" },433        }),434        dispatch({ type: "UPDATE_ENTRY", payload: { id, patch: { username: "v" } } }),435        dispatch({436          type: "ADD_ENTRY",437          payload: { title: "z", username: "u", password: "p", url: "https://e.com" },438        }),439      ]);440      const list = await dispatch({ type: "GET_ENTRIES" });441      expect((list as { data: unknown[] }).data.length).toBe(3);442    });443  });444445  // ── Storage corruption ──────────────────────────────────────────────────446447  describe("storage corruption", () => {448    async function setupVaultInStorage() {449      const vault = await import("./vault");450      await vault.createVault("test-pass-12345");451      vault.lock();452    }453454    it("non-base64 slot A: unlock fails gracefully", async () => {455      await setupVaultInStorage();456      storage.peach_vault_a = "not-a-valid-stored-vault" as unknown;457      const r = await dispatch({458        type: "UNLOCK",459        payload: { password: "test-pass-12345" },460      });461      expect(r.ok).toBe(false);462    });463464    it("missing slot A: unlock fails", async () => {465      await setupVaultInStorage();466      delete storage.peach_vault_a;467      const r = await dispatch({468        type: "UNLOCK",469        payload: { password: "test-pass-12345" },470      });471      expect(r.ok).toBe(false);472    });473474    it("missing slot B: unlock still works from slot A", async () => {475      await setupVaultInStorage();476      delete storage.peach_vault_b;477      const r = await dispatch({478        type: "UNLOCK",479        payload: { password: "test-pass-12345" },480      });481      expect(r.ok).toBe(true);482    });483484    it("corrupt ciphertext in slot A: unlock fails", async () => {485      await setupVaultInStorage();486      const a = storage.peach_vault_a as { ciphertext: string };487      storage.peach_vault_a = { ...a, ciphertext: "AAAA" + a.ciphertext.slice(4) };488      const r = await dispatch({489        type: "UNLOCK",490        payload: { password: "test-pass-12345" },491      });492      expect(r.ok).toBe(false);493    });494495    it("corrupt salt: unlock fails", async () => {496      await setupVaultInStorage();497      const a = storage.peach_vault_a as { salt: string };498      storage.peach_vault_a = { ...a, salt: "00".repeat(16) };499      const r = await dispatch({500        type: "UNLOCK",501        payload: { password: "test-pass-12345" },502      });503      expect(r.ok).toBe(false);504    });505506    it("corrupt nonce: unlock fails", async () => {507      await setupVaultInStorage();508      const a = storage.peach_vault_a as { nonce: string };509      storage.peach_vault_a = { ...a, nonce: "ff".repeat(12) };510      const r = await dispatch({511        type: "UNLOCK",512        payload: { password: "test-pass-12345" },513      });514      expect(r.ok).toBe(false);515    });516517    it("corrupt tag: unlock fails", async () => {518      await setupVaultInStorage();519      const a = storage.peach_vault_a as { tag: string };520      storage.peach_vault_a = { ...a, tag: "00".repeat(16) };521      const r = await dispatch({522        type: "UNLOCK",523        payload: { password: "test-pass-12345" },524      });525      expect(r.ok).toBe(false);526    });527528    it("truncated ciphertext: unlock fails", async () => {529      await setupVaultInStorage();530      const a = storage.peach_vault_a as { ciphertext: string };531      storage.peach_vault_a = { ...a, ciphertext: a.ciphertext.slice(0, 20) };532      const r = await dispatch({533        type: "UNLOCK",534        payload: { password: "test-pass-12345" },535      });536      expect(r.ok).toBe(false);537    });538539    it("empty ciphertext: unlock fails", async () => {540      await setupVaultInStorage();541      const a = storage.peach_vault_a as { ciphertext: string };542      storage.peach_vault_a = { ...a, ciphertext: "" };543      const r = await dispatch({544        type: "UNLOCK",545        payload: { password: "test-pass-12345" },546      });547      expect(r.ok).toBe(false);548    });549550    for (const flipPos of [0, 4, 8, 16, 32, 64]) {551      it(`bit flip in ciphertext at offset ${flipPos}: unlock fails`, async () => {552        await setupVaultInStorage();553        const a = storage.peach_vault_a as { ciphertext: string };554        if (a.ciphertext.length <= flipPos + 1) return;555        const flipped =556          a.ciphertext.slice(0, flipPos) +557          (a.ciphertext[flipPos] === "0" ? "f" : "0") +558          a.ciphertext.slice(flipPos + 1);559        storage.peach_vault_a = { ...a, ciphertext: flipped };560        const r = await dispatch({561          type: "UNLOCK",562          payload: { password: "test-pass-12345" },563        });564        expect(r.ok).toBe(false);565      });566    }567568    it("after corruption, original storage record still exists (no wipe)", async () => {569      await setupVaultInStorage();570      const a = storage.peach_vault_a as { ciphertext: string };571      storage.peach_vault_a = { ...a, ciphertext: "00".repeat(64) };572      await dispatch({573        type: "UNLOCK",574        payload: { password: "test-pass-12345" },575      });576      expect(storage.peach_vault_a).toBeDefined();577    });578  });579580  // ── Unlock attempts / response shape ────────────────────────────────────581582  describe("repeated wrong unlock attempts", () => {583    async function setupLocked() {584      const vault = await import("./vault");585      await vault.createVault("test-pass-12345");586      vault.lock();587    }588589    for (let attempt = 1; attempt <= 10; attempt++) {590      it(`attempt ${attempt}: wrong password returns ok=false`, async () => {591        await setupLocked();592        for (let i = 0; i < attempt - 1; i++) {593          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });594        }595        const r = await dispatch({596          type: "UNLOCK",597          payload: { password: "wrong" },598        });599        expect(r.ok).toBe(false);600      });601    }602603    it(604      "vault data is preserved across 25 wrong attempts",605      async () => {606        await setupLocked();607        for (let i = 0; i < 25; i++) {608          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });609        }610        expect(storage.peach_vault_a).toBeTruthy();611        // Correct password still works.612        const ok = await dispatch({613          type: "UNLOCK",614          payload: { password: "test-pass-12345" },615        });616        expect(ok.ok).toBe(true);617      },618      60_000,619    );620621    it(622      "vault data is preserved across 50 wrong attempts",623      async () => {624        await setupLocked();625        for (let i = 0; i < 50; i++) {626          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });627        }628        expect(storage.peach_vault_a).toBeTruthy();629      },630      120_000,631    );632633    it("successful unlock after wrong attempts produces unlocked state", async () => {634      await setupLocked();635      for (let i = 0; i < 5; i++) {636        await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });637      }638      const r = await dispatch({639        type: "UNLOCK",640        payload: { password: "test-pass-12345" },641      });642      expect(r.ok).toBe(true);643      const status = await dispatch({ type: "IS_UNLOCKED" });644      expect((status as { data: boolean }).data).toBe(true);645    });646647    it("incorrect-then-correct: state is unlocked", async () => {648      await setupLocked();649      await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });650      const r = await dispatch({651        type: "UNLOCK",652        payload: { password: "test-pass-12345" },653      });654      expect(r.ok).toBe(true);655    });656  });657658  // ── Generated random password set ───────────────────────────────────────659660  describe("generated random password fuzz round-trips", () => {661    // 50 deterministic randomly-shaped passwords; each must round-trip.662    const seeded: string[] = [];663    let rng = 0xdeadbeef;664    function next() {665      rng = (rng * 1103515245 + 12345) & 0x7fffffff;666      return rng;667    }668    for (let i = 0; i < 50; i++) {669      const len = (next() % 30) + 4;670      let s = "";671      for (let j = 0; j < len; j++) {672        s += String.fromCharCode((next() % 94) + 33);673      }674      seeded.push(s);675    }676    for (const [i, password] of seeded.entries()) {677      it(`fuzz password #${i} round-trips`, async () => {678        const vault = await import("./vault");679        await vault.createVault(password);680        vault.lock();681        const r = await dispatch({ type: "UNLOCK", payload: { password } });682        expect(r.ok).toBe(true);683      });684685      it(`fuzz password #${i} wrong rejects`, async () => {686        const vault = await import("./vault");687        await vault.createVault(password);688        vault.lock();689        const r = await dispatch({690          type: "UNLOCK",691          payload: { password: password.slice(0, -1) + "Z" },692        });693        expect(r.ok).toBe(false);694      });695    }696  });697698  // ── ADD/DELETE/UPDATE high-volume parameterized ─────────────────────────699700  describe("entry lifecycle parameterized", () => {701    for (const n of [1, 2, 3, 5, 10, 25, 50]) {702      it(`add ${n} entries then delete all leaves no live entries`, async () => {703        const vault = await import("./vault");704        await vault.createVault("test-pass-12345");705        const ids: string[] = [];706        for (let i = 0; i < n; i++) {707          const r = await dispatch({708            type: "ADD_ENTRY",709            payload: {710              title: `E${i}`,711              username: "u",712              password: "p",713              url: "https://e.com",714            },715          });716          ids.push((r as { data: { id: string } }).data.id);717        }718        for (const id of ids) {719          await dispatch({ type: "DELETE_ENTRY", payload: { id } });720        }721        const list = await dispatch({ type: "GET_ENTRIES" });722        expect((list as { data: unknown[] }).data.length).toBe(0);723      });724725      it(`add ${n} entries then update each title`, async () => {726        const vault = await import("./vault");727        await vault.createVault("test-pass-12345");728        const ids: string[] = [];729        for (let i = 0; i < n; i++) {730          const r = await dispatch({731            type: "ADD_ENTRY",732            payload: {733              title: `E${i}`,734              username: "u",735              password: "p",736              url: "https://e.com",737            },738          });739          ids.push((r as { data: { id: string } }).data.id);740        }741        for (const [i, id] of ids.entries()) {742          await dispatch({743            type: "UPDATE_ENTRY",744            payload: { id, patch: { title: `Updated${i}` } },745          });746        }747        const list = await dispatch({ type: "GET_ENTRIES" });748        const titles = (749          list as { data: Array<{ title: string }> }750        ).data.map((e) => e.title);751        for (let i = 0; i < n; i++) {752          expect(titles).toContain(`Updated${i}`);753        }754      });755    }756  });757758  // ── lock/unlock churn ───────────────────────────────────────────────────759760  describe("lock/unlock churn", () => {761    for (const cycles of [1, 2, 3, 5, 10]) {762      it(`${cycles} lock/unlock cycles preserve entries`, async () => {763        const vault = await import("./vault");764        await vault.createVault("test-pass-12345");765        await vault.addEntry({766          title: "Persistent",767          username: "u",768          password: "p",769          url: "https://e.com",770          type: "login",771        });772        for (let i = 0; i < cycles; i++) {773          vault.lock();774          const r = await dispatch({775            type: "UNLOCK",776            payload: { password: "test-pass-12345" },777          });778          expect(r.ok).toBe(true);779        }780        const list = await dispatch({ type: "GET_ENTRIES" });781        expect((list as { data: unknown[] }).data.length).toBe(1);782      });783    }784  });785786  // ── Storage shape adversarial — every slot field corrupted ──────────────787788  describe("per-field corruption matrix", () => {789    async function setup() {790      const vault = await import("./vault");791      await vault.createVault("test-pass-12345");792      vault.lock();793    }794795    const fields = [796      "salt",797      "nonce",798      "ciphertext",799      "tag",800      "version",801    ];802    const corruptions = [803      { name: "empty", value: "" },804      { name: "garbage hex", value: "zzzz" },805      { name: "wrong length 1", value: "ab" },806      { name: "wrong length long", value: "ab".repeat(200) },807      { name: "non-string number", value: 42 as unknown as string },808      { name: "non-string null", value: null as unknown as string },809    ];810    for (const field of fields) {811      for (const { name, value } of corruptions) {812        it(`field=${field} corruption=${name}: unlock fails or response defined`, async () => {813          await setup();814          const a = storage.peach_vault_a as Record<string, unknown>;815          storage.peach_vault_a = { ...a, [field]: value };816          const r = await dispatch({817            type: "UNLOCK",818            payload: { password: "test-pass-12345" },819          });820          expect(r).toBeDefined();821          // Cannot unlock unless the field was already correct.822          if (823            field !== "version" ||824            (typeof value !== "string" && value !== null)825          ) {826            expect(r.ok).toBe(false);827          }828        });829      }830    }831  });832833  // ── Corruption distinguished from wrong password (M7/COR-007) ───────────834835  describe("vault corruption surfaces vault_corrupted error", () => {836    async function setup() {837      const vault = await import("./vault");838      await vault.createVault("test-pass-12345");839      vault.lock();840    }841842    it("schema-invalid slot (version=42) returns vault_corrupted with canRestoreFromHistory", async () => {843      await setup();844      const a = storage.peach_vault_a as Record<string, unknown>;845      storage.peach_vault_a = { ...a, version: 42 };846      const r = await dispatch({847        type: "UNLOCK",848        payload: { password: "test-pass-12345" },849      });850      expect(r.ok).toBe(false);851      expect((r as { error?: string }).error).toBe("vault_corrupted");852      expect((r as { canRestoreFromHistory?: boolean }).canRestoreFromHistory).toBe(true);853    });854855    it("wrong password (correct schema) does NOT return vault_corrupted", async () => {856      await setup();857      const r = await dispatch({858        type: "UNLOCK",859        payload: { password: "wrong-password" },860      });861      expect(r.ok).toBe(false);862      expect((r as { error?: string }).error).not.toBe("vault_corrupted");863    });864  });865866  // ── Many "add then lock then unlock" sequences ──────────────────────────867868  describe("add-then-relock churn", () => {869    for (let seed = 0; seed < 20; seed++) {870      it(`seed ${seed}: add 3 entries with seeded titles survives lock/unlock`, async () => {871        const vault = await import("./vault");872        await vault.createVault("test-pass-12345");873        for (let i = 0; i < 3; i++) {874          await vault.addEntry({875            title: `seed-${seed}-${i}`,876            username: `u${i}`,877            password: `p${i}`,878            url: `https://e${seed}.com`,879            type: "login",880          });881        }882        vault.lock();883        const r = await dispatch({884          type: "UNLOCK",885          payload: { password: "test-pass-12345" },886        });887        expect(r.ok).toBe(true);888        const list = await dispatch({ type: "GET_ENTRIES" });889        expect((list as { data: unknown[] }).data.length).toBe(3);890      });891    }892  });893894  // ── Sanity / no-crash checks across many invocations ────────────────────895896  describe("UNLOCK no-crash matrix", () => {897    const adversarialPayloads = [898      { name: "null password", payload: { password: null as unknown as string } },899      { name: "undefined password", payload: { password: undefined as unknown as string } },900      { name: "number password", payload: { password: 12345 as unknown as string } },901      { name: "bool password", payload: { password: true as unknown as string } },902      { name: "array password", payload: { password: ["a", "b"] as unknown as string } },903      { name: "object password", payload: { password: { x: 1 } as unknown as string } },904      { name: "missing password field", payload: {} as { password: string } },905      { name: "empty password", payload: { password: "" } },906    ];907908    for (const { name, payload } of adversarialPayloads) {909      it(`UNLOCK with ${name} resolves without crash`, async () => {910        const vault = await import("./vault");911        await vault.createVault("test-pass-12345");912        vault.lock();913        const r = await dispatch({ type: "UNLOCK", payload });914        expect(r).toBeDefined();915      });916    }917  });918});