vault.adversarial.test.ts

31.3 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 accepted", async () => {229      await setupUnlocked();230      const r = await dispatch({231        type: "ADD_ENTRY",232        payload: { title: "", username: "u", password: "test", url: "https://e.com" },233      });234      expect(r.ok).toBe(true);235    });236237    const titleCases = [238      { name: "1 char title", title: "x" },239      { name: "100 char title", title: "x".repeat(100) },240      { name: "1000 char title", title: "x".repeat(1000) },241      { name: "10000 char title", title: "x".repeat(10000) },242      { name: "unicode title", title: "日本語 🔑 Ünïcödé" },243      { name: "emoji title", title: "🔑🔒🛡️🌸" },244      { name: "RTL title", title: "كلمة المرور" },245      { name: "tab in title", title: "Tab\there" },246      { name: "newline in title", title: "Line1\nLine2" },247      { name: "html-looking title", title: "<script>alert(1)</script>" },248      { name: "sql-looking title", title: "' OR '1'='1" },249      { name: "null character in title", title: "before\x00after" },250      { name: "control chars title", title: "\x01\x02\x03\x04" },251    ];252253    for (const { name, title } of titleCases) {254      it(`entry with ${name} is handled without crash`, async () => {255        await setupUnlocked();256        const r = await dispatch({257          type: "ADD_ENTRY",258          payload: { title, username: "u", password: "p", url: "https://e.com" },259        });260        expect(r).toBeDefined();261      });262263      it(`entry with ${name} round-trips title via GET_ENTRIES`, async () => {264        await setupUnlocked();265        await dispatch({266          type: "ADD_ENTRY",267          payload: { title, username: "u", password: "p", url: "https://e.com" },268        });269        const list = await dispatch({ type: "GET_ENTRIES" });270        expect(list.ok).toBe(true);271        const entries = (list as { data: Array<{ title: string }> }).data;272        expect(entries.length).toBe(1);273        // Title is preserved as-is (no sanitisation/escaping).274        expect(entries[0].title).toBe(title);275      });276    }277278    const urlCases = [279      { name: "https URL", url: "https://github.com" },280      { name: "http URL", url: "http://example.com" },281      { name: "javascript URL", url: "javascript:alert('xss')" },282      { name: "data URL", url: "data:text/html,<script>alert(1)</script>" },283      { name: "file URL", url: "file:///etc/passwd" },284      { name: "empty URL", url: "" },285      { name: "spaces URL", url: "   " },286      { name: "ftp URL", url: "ftp://example.com" },287      { name: "IPv6 URL", url: "https://[::1]" },288      { name: "IDN URL", url: "https://日本.jp" },289      { name: "very long URL", url: "https://example.com/" + "a".repeat(5000) },290    ];291292    for (const { name, url } of urlCases) {293      it(`entry with ${name} stored as-is`, async () => {294        await setupUnlocked();295        const r = await dispatch({296          type: "ADD_ENTRY",297          payload: { title: "t", username: "u", password: "p", url },298        });299        expect(r.ok).toBe(true);300      });301    }302303    it("100 entries: count consistent", async () => {304      await setupUnlocked();305      for (let i = 0; i < 100; i++) {306        await dispatch({307          type: "ADD_ENTRY",308          payload: { title: `E${i}`, username: "u", password: `p${i}`, url: "https://e.com" },309        });310      }311      const list = await dispatch({ type: "GET_ENTRIES" });312      expect((list as { data: unknown[] }).data.length).toBe(100);313    });314315    it("vault remains unlockable after many entries", async () => {316      await setupUnlocked();317      const vault = await import("./vault");318      for (let i = 0; i < 50; i++) {319        await vault.addEntry({320          title: `E${i}`,321          username: "u",322          password: `p${i}`,323          url: "https://e.com",324          type: "login",325        });326      }327      vault.lock();328      const unlock = await dispatch({329        type: "UNLOCK",330        payload: { password: "test-pass-12345" },331      });332      expect(unlock.ok).toBe(true);333      const list = await dispatch({ type: "GET_ENTRIES" });334      expect((list as { data: unknown[] }).data.length).toBe(50);335    });336337    it("add then delete leaves no live entries", async () => {338      await setupUnlocked();339      const r = await dispatch({340        type: "ADD_ENTRY",341        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },342      });343      const id = (r as { data: { id: string } }).data.id;344      await dispatch({ type: "DELETE_ENTRY", payload: { id } });345      const list = await dispatch({ type: "GET_ENTRIES" });346      expect((list as { data: unknown[] }).data.length).toBe(0);347    });348  });349350  // ── Concurrent operations ───────────────────────────────────────────────351352  describe("concurrent operations", () => {353    async function setupLocked() {354      const vault = await import("./vault");355      await vault.createVault("test-pass-12345");356      vault.lock();357    }358359    it("simultaneous UNLOCK calls leave vault unlocked", async () => {360      await setupLocked();361      const [r1, r2] = await Promise.all([362        dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),363        dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),364      ]);365      expect(r1.ok || r2.ok).toBe(true);366      const status = await dispatch({ type: "IS_UNLOCKED" });367      expect((status as { data: boolean }).data).toBe(true);368    });369370    it("simultaneous wrong UNLOCKs leave vault locked", async () => {371      await setupLocked();372      await Promise.all([373        dispatch({ type: "UNLOCK", payload: { password: "wrong-1" } }),374        dispatch({ type: "UNLOCK", payload: { password: "wrong-2" } }),375      ]);376      const status = await dispatch({ type: "IS_UNLOCKED" });377      expect((status as { data: boolean }).data).toBe(false);378    });379380    it("ADD_ENTRY during LOCK does not corrupt vault", async () => {381      const vault = await import("./vault");382      await vault.createVault("test-pass-12345");383      await Promise.all([384        dispatch({385          type: "ADD_ENTRY",386          payload: { title: "t", username: "u", password: "p", url: "https://e.com" },387        }).catch(() => null),388        dispatch({ type: "LOCK" }),389      ]);390      const unlock = await dispatch({391        type: "UNLOCK",392        payload: { password: "test-pass-12345" },393      });394      expect(unlock.ok).toBe(true);395    });396397    for (const count of [5, 10, 20, 50]) {398      it(`simultaneous ADD_ENTRY x ${count} produce that many entries`, async () => {399        const vault = await import("./vault");400        await vault.createVault("test-pass-12345");401        await Promise.all(402          Array.from({ length: count }, (_, i) =>403            dispatch({404              type: "ADD_ENTRY",405              payload: {406                title: `E${i}`,407                username: "u",408                password: "p",409                url: "https://e.com",410              },411            })412          )413        );414        const list = await dispatch({ type: "GET_ENTRIES" });415        expect((list as { data: unknown[] }).data.length).toBe(count);416      });417    }418419    it("interleaved ADD + UPDATE keeps entry counts stable", async () => {420      const vault = await import("./vault");421      await vault.createVault("test-pass-12345");422      const r = await dispatch({423        type: "ADD_ENTRY",424        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },425      });426      const id = (r as { data: { id: string } }).data.id;427      await Promise.all([428        dispatch({429          type: "ADD_ENTRY",430          payload: { title: "y", username: "u", password: "p", url: "https://e.com" },431        }),432        dispatch({ type: "UPDATE_ENTRY", payload: { id, patch: { username: "v" } } }),433        dispatch({434          type: "ADD_ENTRY",435          payload: { title: "z", username: "u", password: "p", url: "https://e.com" },436        }),437      ]);438      const list = await dispatch({ type: "GET_ENTRIES" });439      expect((list as { data: unknown[] }).data.length).toBe(3);440    });441  });442443  // ── Storage corruption ──────────────────────────────────────────────────444445  describe("storage corruption", () => {446    async function setupVaultInStorage() {447      const vault = await import("./vault");448      await vault.createVault("test-pass-12345");449      vault.lock();450    }451452    it("non-base64 slot A: unlock fails gracefully", async () => {453      await setupVaultInStorage();454      storage.peach_vault_a = "not-a-valid-stored-vault" as unknown;455      const r = await dispatch({456        type: "UNLOCK",457        payload: { password: "test-pass-12345" },458      });459      expect(r.ok).toBe(false);460    });461462    it("missing slot A: unlock fails", async () => {463      await setupVaultInStorage();464      delete storage.peach_vault_a;465      const r = await dispatch({466        type: "UNLOCK",467        payload: { password: "test-pass-12345" },468      });469      expect(r.ok).toBe(false);470    });471472    it("missing slot B: unlock still works from slot A", async () => {473      await setupVaultInStorage();474      delete storage.peach_vault_b;475      const r = await dispatch({476        type: "UNLOCK",477        payload: { password: "test-pass-12345" },478      });479      expect(r.ok).toBe(true);480    });481482    it("corrupt ciphertext in slot A: unlock fails", async () => {483      await setupVaultInStorage();484      const a = storage.peach_vault_a as { ciphertext: string };485      storage.peach_vault_a = { ...a, ciphertext: "AAAA" + a.ciphertext.slice(4) };486      const r = await dispatch({487        type: "UNLOCK",488        payload: { password: "test-pass-12345" },489      });490      expect(r.ok).toBe(false);491    });492493    it("corrupt salt: unlock fails", async () => {494      await setupVaultInStorage();495      const a = storage.peach_vault_a as { salt: string };496      storage.peach_vault_a = { ...a, salt: "00".repeat(16) };497      const r = await dispatch({498        type: "UNLOCK",499        payload: { password: "test-pass-12345" },500      });501      expect(r.ok).toBe(false);502    });503504    it("corrupt nonce: unlock fails", async () => {505      await setupVaultInStorage();506      const a = storage.peach_vault_a as { nonce: string };507      storage.peach_vault_a = { ...a, nonce: "ff".repeat(12) };508      const r = await dispatch({509        type: "UNLOCK",510        payload: { password: "test-pass-12345" },511      });512      expect(r.ok).toBe(false);513    });514515    it("corrupt tag: unlock fails", async () => {516      await setupVaultInStorage();517      const a = storage.peach_vault_a as { tag: string };518      storage.peach_vault_a = { ...a, tag: "00".repeat(16) };519      const r = await dispatch({520        type: "UNLOCK",521        payload: { password: "test-pass-12345" },522      });523      expect(r.ok).toBe(false);524    });525526    it("truncated ciphertext: unlock fails", async () => {527      await setupVaultInStorage();528      const a = storage.peach_vault_a as { ciphertext: string };529      storage.peach_vault_a = { ...a, ciphertext: a.ciphertext.slice(0, 20) };530      const r = await dispatch({531        type: "UNLOCK",532        payload: { password: "test-pass-12345" },533      });534      expect(r.ok).toBe(false);535    });536537    it("empty ciphertext: unlock fails", async () => {538      await setupVaultInStorage();539      const a = storage.peach_vault_a as { ciphertext: string };540      storage.peach_vault_a = { ...a, ciphertext: "" };541      const r = await dispatch({542        type: "UNLOCK",543        payload: { password: "test-pass-12345" },544      });545      expect(r.ok).toBe(false);546    });547548    for (const flipPos of [0, 4, 8, 16, 32, 64]) {549      it(`bit flip in ciphertext at offset ${flipPos}: unlock fails`, async () => {550        await setupVaultInStorage();551        const a = storage.peach_vault_a as { ciphertext: string };552        if (a.ciphertext.length <= flipPos + 1) return;553        const flipped =554          a.ciphertext.slice(0, flipPos) +555          (a.ciphertext[flipPos] === "0" ? "f" : "0") +556          a.ciphertext.slice(flipPos + 1);557        storage.peach_vault_a = { ...a, ciphertext: flipped };558        const r = await dispatch({559          type: "UNLOCK",560          payload: { password: "test-pass-12345" },561        });562        expect(r.ok).toBe(false);563      });564    }565566    it("after corruption, original storage record still exists (no wipe)", async () => {567      await setupVaultInStorage();568      const a = storage.peach_vault_a as { ciphertext: string };569      storage.peach_vault_a = { ...a, ciphertext: "00".repeat(64) };570      await dispatch({571        type: "UNLOCK",572        payload: { password: "test-pass-12345" },573      });574      expect(storage.peach_vault_a).toBeDefined();575    });576  });577578  // ── Unlock attempts / response shape ────────────────────────────────────579580  describe("repeated wrong unlock attempts", () => {581    async function setupLocked() {582      const vault = await import("./vault");583      await vault.createVault("test-pass-12345");584      vault.lock();585    }586587    for (let attempt = 1; attempt <= 10; attempt++) {588      it(`attempt ${attempt}: wrong password returns ok=false`, async () => {589        await setupLocked();590        for (let i = 0; i < attempt - 1; i++) {591          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });592        }593        const r = await dispatch({594          type: "UNLOCK",595          payload: { password: "wrong" },596        });597        expect(r.ok).toBe(false);598      });599    }600601    it(602      "vault data is preserved across 25 wrong attempts",603      async () => {604        await setupLocked();605        for (let i = 0; i < 25; i++) {606          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });607        }608        expect(storage.peach_vault_a).toBeTruthy();609        // Correct password still works.610        const ok = await dispatch({611          type: "UNLOCK",612          payload: { password: "test-pass-12345" },613        });614        expect(ok.ok).toBe(true);615      },616      60_000,617    );618619    it(620      "vault data is preserved across 50 wrong attempts",621      async () => {622        await setupLocked();623        for (let i = 0; i < 50; i++) {624          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });625        }626        expect(storage.peach_vault_a).toBeTruthy();627      },628      120_000,629    );630631    it("successful unlock after wrong attempts produces unlocked state", async () => {632      await setupLocked();633      for (let i = 0; i < 5; i++) {634        await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });635      }636      const r = await dispatch({637        type: "UNLOCK",638        payload: { password: "test-pass-12345" },639      });640      expect(r.ok).toBe(true);641      const status = await dispatch({ type: "IS_UNLOCKED" });642      expect((status as { data: boolean }).data).toBe(true);643    });644645    it("incorrect-then-correct: state is unlocked", async () => {646      await setupLocked();647      await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });648      const r = await dispatch({649        type: "UNLOCK",650        payload: { password: "test-pass-12345" },651      });652      expect(r.ok).toBe(true);653    });654  });655656  // ── Generated random password set ───────────────────────────────────────657658  describe("generated random password fuzz round-trips", () => {659    // 50 deterministic randomly-shaped passwords; each must round-trip.660    const seeded: string[] = [];661    let rng = 0xdeadbeef;662    function next() {663      rng = (rng * 1103515245 + 12345) & 0x7fffffff;664      return rng;665    }666    for (let i = 0; i < 50; i++) {667      const len = (next() % 30) + 4;668      let s = "";669      for (let j = 0; j < len; j++) {670        s += String.fromCharCode((next() % 94) + 33);671      }672      seeded.push(s);673    }674    for (const [i, password] of seeded.entries()) {675      it(`fuzz password #${i} round-trips`, async () => {676        const vault = await import("./vault");677        await vault.createVault(password);678        vault.lock();679        const r = await dispatch({ type: "UNLOCK", payload: { password } });680        expect(r.ok).toBe(true);681      });682683      it(`fuzz password #${i} wrong rejects`, async () => {684        const vault = await import("./vault");685        await vault.createVault(password);686        vault.lock();687        const r = await dispatch({688          type: "UNLOCK",689          payload: { password: password.slice(0, -1) + "Z" },690        });691        expect(r.ok).toBe(false);692      });693    }694  });695696  // ── ADD/DELETE/UPDATE high-volume parameterized ─────────────────────────697698  describe("entry lifecycle parameterized", () => {699    for (const n of [1, 2, 3, 5, 10, 25, 50]) {700      it(`add ${n} entries then delete all leaves no live entries`, async () => {701        const vault = await import("./vault");702        await vault.createVault("test-pass-12345");703        const ids: string[] = [];704        for (let i = 0; i < n; i++) {705          const r = await dispatch({706            type: "ADD_ENTRY",707            payload: {708              title: `E${i}`,709              username: "u",710              password: "p",711              url: "https://e.com",712            },713          });714          ids.push((r as { data: { id: string } }).data.id);715        }716        for (const id of ids) {717          await dispatch({ type: "DELETE_ENTRY", payload: { id } });718        }719        const list = await dispatch({ type: "GET_ENTRIES" });720        expect((list as { data: unknown[] }).data.length).toBe(0);721      });722723      it(`add ${n} entries then update each title`, async () => {724        const vault = await import("./vault");725        await vault.createVault("test-pass-12345");726        const ids: string[] = [];727        for (let i = 0; i < n; i++) {728          const r = await dispatch({729            type: "ADD_ENTRY",730            payload: {731              title: `E${i}`,732              username: "u",733              password: "p",734              url: "https://e.com",735            },736          });737          ids.push((r as { data: { id: string } }).data.id);738        }739        for (const [i, id] of ids.entries()) {740          await dispatch({741            type: "UPDATE_ENTRY",742            payload: { id, patch: { title: `Updated${i}` } },743          });744        }745        const list = await dispatch({ type: "GET_ENTRIES" });746        const titles = (747          list as { data: Array<{ title: string }> }748        ).data.map((e) => e.title);749        for (let i = 0; i < n; i++) {750          expect(titles).toContain(`Updated${i}`);751        }752      });753    }754  });755756  // ── lock/unlock churn ───────────────────────────────────────────────────757758  describe("lock/unlock churn", () => {759    for (const cycles of [1, 2, 3, 5, 10]) {760      it(`${cycles} lock/unlock cycles preserve entries`, async () => {761        const vault = await import("./vault");762        await vault.createVault("test-pass-12345");763        await vault.addEntry({764          title: "Persistent",765          username: "u",766          password: "p",767          url: "https://e.com",768          type: "login",769        });770        for (let i = 0; i < cycles; i++) {771          vault.lock();772          const r = await dispatch({773            type: "UNLOCK",774            payload: { password: "test-pass-12345" },775          });776          expect(r.ok).toBe(true);777        }778        const list = await dispatch({ type: "GET_ENTRIES" });779        expect((list as { data: unknown[] }).data.length).toBe(1);780      });781    }782  });783784  // ── Storage shape adversarial — every slot field corrupted ──────────────785786  describe("per-field corruption matrix", () => {787    async function setup() {788      const vault = await import("./vault");789      await vault.createVault("test-pass-12345");790      vault.lock();791    }792793    const fields = [794      "salt",795      "nonce",796      "ciphertext",797      "tag",798      "version",799    ];800    const corruptions = [801      { name: "empty", value: "" },802      { name: "garbage hex", value: "zzzz" },803      { name: "wrong length 1", value: "ab" },804      { name: "wrong length long", value: "ab".repeat(200) },805      { name: "non-string number", value: 42 as unknown as string },806      { name: "non-string null", value: null as unknown as string },807    ];808    for (const field of fields) {809      for (const { name, value } of corruptions) {810        it(`field=${field} corruption=${name}: unlock fails or response defined`, async () => {811          await setup();812          const a = storage.peach_vault_a as Record<string, unknown>;813          storage.peach_vault_a = { ...a, [field]: value };814          const r = await dispatch({815            type: "UNLOCK",816            payload: { password: "test-pass-12345" },817          });818          expect(r).toBeDefined();819          // Cannot unlock unless the field was already correct.820          if (821            field !== "version" ||822            (typeof value !== "string" && value !== null)823          ) {824            expect(r.ok).toBe(false);825          }826        });827      }828    }829  });830831  // ── Many "add then lock then unlock" sequences ──────────────────────────832833  describe("add-then-relock churn", () => {834    for (let seed = 0; seed < 20; seed++) {835      it(`seed ${seed}: add 3 entries with seeded titles survives lock/unlock`, async () => {836        const vault = await import("./vault");837        await vault.createVault("test-pass-12345");838        for (let i = 0; i < 3; i++) {839          await vault.addEntry({840            title: `seed-${seed}-${i}`,841            username: `u${i}`,842            password: `p${i}`,843            url: `https://e${seed}.com`,844            type: "login",845          });846        }847        vault.lock();848        const r = await dispatch({849          type: "UNLOCK",850          payload: { password: "test-pass-12345" },851        });852        expect(r.ok).toBe(true);853        const list = await dispatch({ type: "GET_ENTRIES" });854        expect((list as { data: unknown[] }).data.length).toBe(3);855      });856    }857  });858859  // ── Sanity / no-crash checks across many invocations ────────────────────860861  describe("UNLOCK no-crash matrix", () => {862    const adversarialPayloads = [863      { name: "null password", payload: { password: null as unknown as string } },864      { name: "undefined password", payload: { password: undefined as unknown as string } },865      { name: "number password", payload: { password: 12345 as unknown as string } },866      { name: "bool password", payload: { password: true as unknown as string } },867      { name: "array password", payload: { password: ["a", "b"] as unknown as string } },868      { name: "object password", payload: { password: { x: 1 } as unknown as string } },869      { name: "missing password field", payload: {} as { password: string } },870      { name: "empty password", payload: { password: "" } },871    ];872873    for (const { name, payload } of adversarialPayloads) {874      it(`UNLOCK with ${name} resolves without crash`, async () => {875        const vault = await import("./vault");876        await vault.createVault("test-pass-12345");877        vault.lock();878        const r = await dispatch({ type: "UNLOCK", payload });879        expect(r).toBeDefined();880      });881    }882  });883});