messages.adversarial.test.ts

35.3 KB · raw

1/**2 * messages.adversarial.test.ts — Layer 3 §3.3 adversarial message handler tests.3 *4 * Exercises every payload-bearing handler with null/undefined/empty/nested5 * payloads, unknown message types, AUTOFILL_QUERY edge cases, and Codex6 * restore boundary inputs. Parameterized for >=300 tests.7 *8 * Where the audit references handlers Peach does not implement9 * (IMPORT_PEACHSCRIPT / RESTORE_FROM_PEACHSCRIPT), the tests confirm the10 * default `unknown message type` path responds without crashing — see the11 * 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// Payload-bearing handlers we exercise with adversarial payloads.26const PAYLOAD_HANDLERS = [27  "UNLOCK",28  "CREATE_VAULT",29  "ADD_ENTRY",30  "UPDATE_ENTRY",31  "DELETE_ENTRY",32  "BATCH_ADD_ENTRIES",33  "RESTORE_ENTRIES",34  "ADD_ADDITIONAL_URL",35  "RESTORE_ENTRY",36  "PURGE_ENTRY",37  "PURGE_EXPIRED_TRASH",38  "SAVE_NOTE",39  "DELETE_NOTE",40  "RECOVER_WITH_WORDS",41  "VALIDATE_RECOVERY_WORDS",42  "RESTORE_FROM_CODEX",43  "SCAN_PAIRING_QR",44  "ACCEPT_PAIRING",45  "REMOVE_PAIRED_DEVICE",46  "REMOVE_TRUSTED_NETWORK",47  "AUTOFILL_QUERY",48  "GET_ENTRY_FOR_FILL",49  "COPY",50  "UPDATE_SETTINGS",51  "APPROVE_PASSKEY",52  "DENY_PASSKEY",53  "SET_PASSKEY_PROVIDER_ENABLED",54  "ACTIVATE_LICENSE",55  "RESPOND_INCOMING_PAIRING",56  "DEBUG_INJECT_INCOMING_PAIRING",57  "RESTORE_VAULT_SNAPSHOT",58  "CHANGE_PASSWORD",59  "SETUP_DURESS",60  "VERIFY_MAIN_PASSWORD",61  "FIRE_CANARY_PING",62  "ML_PREDICT_BATCH",63  "SEARCH_VAULT",64  "GET_PASSKEY_FOR_DOMAIN",65];6667describe("message handler adversarial inputs", () => {68  let storage: StorageShape;69  let sessionStorage: StorageShape;70  let listener: MessageListener | undefined;7172  beforeEach(async () => {73    vi.resetModules();74    vi.useRealTimers();75    storage = { peach_settings: { breachMonitoring: false } };76    sessionStorage = {};77    listener = undefined;7879    vi.stubGlobal(80      "fetch",81      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))82    );83    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);8485    const chromeMock = {86      runtime: {87        id: "peach-extension",88        onMessage: {89          addListener: vi.fn((fn: MessageListener) => {90            listener = fn;91          }),92        },93        sendMessage: vi.fn(async () => ({ ok: true })),94      },95      tabs: {96        query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {97          if (typeof cb === "function") {98            cb([{ id: 1 }]);99            return;100          }101          return Promise.resolve([{ id: 1 }]);102        }),103        sendMessage: vi.fn(async () => ({ ok: true })),104      },105      storage: {106        local: {107          get: vi.fn(async (key?: string | string[]) => {108            if (!key) return storage;109            if (typeof key === "string") return { [key]: storage[key] };110            return key.reduce<Record<string, unknown>>((acc, k) => {111              acc[k] = storage[k];112              return acc;113            }, {});114          }),115          set: vi.fn(async (next: Record<string, unknown>) => {116            Object.assign(storage, next);117          }),118          remove: vi.fn(async (keys: string | string[]) => {119            const list = Array.isArray(keys) ? keys : [keys];120            for (const key of list) delete storage[key];121          }),122        },123        session: {124          get: vi.fn(async (key?: string | string[]) => {125            if (!key) return sessionStorage;126            if (typeof key === "string") return { [key]: sessionStorage[key] };127            return key.reduce<Record<string, unknown>>((acc, k) => {128              acc[k] = sessionStorage[k];129              return acc;130            }, {});131          }),132          set: vi.fn(async (next: Record<string, unknown>) => {133            Object.assign(sessionStorage, next);134          }),135          remove: vi.fn(async (keys: string | string[]) => {136            const list = Array.isArray(keys) ? keys : [keys];137            for (const key of list) delete sessionStorage[key];138          }),139          clear: vi.fn(async () => {140            for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];141          }),142        },143      },144      alarms: {145        clear: vi.fn(),146        create: vi.fn(),147        onAlarm: { addListener: vi.fn() },148      },149      idle: {150        setDetectionInterval: vi.fn(),151        onStateChanged: { addListener: vi.fn() },152      },153      scripting: { executeScript: vi.fn(async () => []) },154    } as unknown as typeof chrome;155156    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;157158    const messages = await import("./messages");159    messages.setupMessageHandler();160    const vault = await import("./vault");161    await vault.ensureSettingsLoaded();162  });163164  async function dispatch(message: Message): Promise<MessageResponse> {165    if (!listener) throw new Error("listener not registered");166    return new Promise<MessageResponse>((resolve) => {167      const keepOpen = listener!(168        message,169        { id: "peach-extension" } as chrome.runtime.MessageSender,170        resolve171      );172      expect(keepOpen).toBe(true);173    });174  }175176  async function setupUnlockedVault() {177    const vault = await import("./vault");178    await vault.createVault("test-pass-12345");179  }180181  // ── Null/undefined/empty/nested payload matrix ──────────────────────────182183  describe("payload no-crash matrix", () => {184    for (const type of PAYLOAD_HANDLERS) {185      it(`${type} with null payload does not crash`, async () => {186        const r = await dispatch({ type, payload: null } as unknown as Message);187        expect(r).toBeDefined();188      });189190      it(`${type} with undefined payload does not crash`, async () => {191        const r = await dispatch({192          type,193          payload: undefined,194        } as unknown as Message);195        expect(r).toBeDefined();196      });197198      it(`${type} with empty object payload does not crash`, async () => {199        const r = await dispatch({ type, payload: {} } as unknown as Message);200        expect(r).toBeDefined();201      });202203      it(`${type} with deeply nested payload does not crash`, async () => {204        const nested: Record<string, unknown> = {};205        let cur: Record<string, unknown> = nested;206        for (let i = 0; i < 50; i++) {207          const child: Record<string, unknown> = {};208          cur.child = child;209          cur = child;210        }211        const r = await dispatch({212          type,213          payload: nested,214        } as unknown as Message);215        expect(r).toBeDefined();216      });217218      it(`${type} with array payload does not crash`, async () => {219        const r = await dispatch({220          type,221          payload: [1, 2, 3],222        } as unknown as Message);223        expect(r).toBeDefined();224      });225226      it(`${type} with string payload does not crash`, async () => {227        const r = await dispatch({228          type,229          payload: "garbage",230        } as unknown as Message);231        expect(r).toBeDefined();232      });233234      it(`${type} with number payload does not crash`, async () => {235        const r = await dispatch({236          type,237          payload: 42,238        } as unknown as Message);239        expect(r).toBeDefined();240      });241    }242  });243244  // ── Unknown / malformed message types ───────────────────────────────────245246  describe("unknown message types", () => {247    const unknownTypes = [248      "TOTALLY_UNKNOWN_TYPE",249      "",250      "X",251      "lowercase_type",252      "with spaces in type",253      "with-dashes",254      "WITH.DOTS",255      "🌸emoji_type",256      "Unicode_üñïcödé",257      "IMPORT_PEACHSCRIPT",258      "RESTORE_FROM_PEACHSCRIPT",259      "DESTROY_VAULT_QUIETLY",260      "ESCALATE_PRIVILEGE",261    ];262263    for (const type of unknownTypes) {264      it(`unknown type "${type}" returns error response`, async () => {265        const r = await dispatch({ type } as unknown as Message);266        expect(r).toBeDefined();267        expect(r.ok).toBe(false);268      });269    }270271    it("missing type field does not crash", async () => {272      const r = await dispatch({} as unknown as Message);273      expect(r).toBeDefined();274    });275276    it("type=null does not crash", async () => {277      const r = await dispatch({ type: null } as unknown as Message);278      expect(r).toBeDefined();279    });280281    it("type=number does not crash", async () => {282      const r = await dispatch({ type: 42 } as unknown as Message);283      expect(r).toBeDefined();284    });285  });286287  // ── Sender validation ────────────────────────────────────────────────────288289  describe("sender validation", () => {290    it("foreign extension sender is rejected", async () => {291      if (!listener) throw new Error("listener not registered");292      const r = await new Promise<MessageResponse>((resolve) => {293        listener!(294          { type: "GET_ENTRIES" } as Message,295          { id: "foreign-extension" } as chrome.runtime.MessageSender,296          resolve297        );298      });299      expect(r.ok).toBe(false);300      expect(r.error).toBe("invalid_sender");301    });302303    it("sender with no id (extension UI) is accepted", async () => {304      if (!listener) throw new Error("listener not registered");305      await setupUnlockedVault();306      const r = await new Promise<MessageResponse>((resolve) => {307        listener!(308          { type: "GET_ENTRIES" } as Message,309          {} as chrome.runtime.MessageSender,310          resolve311        );312      });313      expect(r.ok).toBe(true);314    });315  });316317  // ── AUTOFILL_QUERY adversarial ──────────────────────────────────────────318319  describe("AUTOFILL_QUERY adversarial", () => {320    async function withEntries(n: number) {321      const vault = await import("./vault");322      await vault.createVault("test-pass-12345");323      for (let i = 0; i < n; i++) {324        await vault.addEntry({325          title: `E${i}`,326          username: `u${i}`,327          password: "p",328          url: `https://example${i}.com`,329          type: "login",330        });331      }332    }333334    it("empty domain returns empty results", async () => {335      await withEntries(3);336      const r = await dispatch({337        type: "AUTOFILL_QUERY",338        payload: { domain: "" },339      });340      expect(r.ok).toBe(true);341      expect((r as { data: unknown[] }).data).toEqual([]);342    });343344    const adversarialDomains = [345      "*",346      ".*",347      "%",348      "<script>alert(1)</script>",349      "' OR '1'='1",350      "../../../etc/passwd",351      "\x00github.com",352      "github.com\x00",353      "GITHUB.COM",354      "github.com/path",355      "https://github.com",356      "javascript:alert(1)",357      " ",358      "   ",359      "\t",360      "\n",361      String("a").repeat(5000),362    ];363    for (const domain of adversarialDomains) {364      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not crash`, async () => {365        await withEntries(3);366        const r = await dispatch({367          type: "AUTOFILL_QUERY",368          payload: { domain },369        });370        expect(r.ok).toBe(true);371      });372373      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not match all entries`, async () => {374        await withEntries(5);375        const r = await dispatch({376          type: "AUTOFILL_QUERY",377          payload: { domain },378        });379        const data = (r as { data: unknown[] }).data;380        // No adversarial wildcard should ever match every entry.381        expect(data.length).toBeLessThan(5);382      });383    }384385    it("locked vault: AUTOFILL_QUERY returns error", async () => {386      const vault = await import("./vault");387      await vault.createVault("test-pass-12345");388      vault.lock();389      const r = await dispatch({390        type: "AUTOFILL_QUERY",391        payload: { domain: "github.com" },392      });393      expect(r.ok).toBe(false);394    });395396    it("response never leaks privateKeyJwk", async () => {397      await withEntries(3);398      const r = await dispatch({399        type: "AUTOFILL_QUERY",400        payload: { domain: "example0.com" },401      });402      const data = (r as { data: Array<Record<string, unknown>> }).data;403      for (const entry of data) {404        expect("privateKeyJwk" in entry).toBe(false);405        expect("passkey" in entry).toBe(false);406      }407    });408409    it("kind=card returns card summaries only (no full PAN)", async () => {410      const vault = await import("./vault");411      await vault.createVault("test-pass-12345");412      await vault.addEntry({413        type: "card",414        title: "Visa",415        cardNumber: "4111111111111111",416        last4: "1111",417        cardholderName: "JUNE",418        expMonth: "12",419        expYear: "2030",420        cvc: "123",421      } as unknown as Parameters<typeof vault.addEntry>[0]);422      const r = await dispatch({423        type: "AUTOFILL_QUERY",424        payload: { domain: "any.com", kind: "card" },425      });426      const data = (r as { data: Array<Record<string, unknown>> }).data;427      for (const entry of data) {428        expect("cardNumber" in entry).toBe(false);429        expect("cvc" in entry).toBe(false);430      }431    });432  });433434  // ── RESTORE_FROM_CODEX adversarial ──────────────────────────────────────435436  describe("RESTORE_FROM_CODEX adversarial", () => {437    const malformed = [438      { name: "empty vaultBlob", overrides: { vaultBlob: "" } },439      { name: "garbage vaultBlob", overrides: { vaultBlob: "!!!not-base64!!!" } },440      { name: "wrong fingerprint", overrides: { vaultFingerprint: "0".repeat(16) } },441      { name: "wrong passphrase", overrides: { masterPassphrase: "definitely wrong" } },442      { name: "negative timestamp", overrides: { timestamp: -1 } },443      { name: "zero timestamp", overrides: { timestamp: 0 } },444      { name: "huge entryCount", overrides: { entryCount: 1_000_000 } },445      { name: "negative entryCount", overrides: { entryCount: -5 } },446    ];447    for (const { name, overrides } of malformed) {448      it(`malformed payload (${name}) returns ok=false without crash`, async () => {449        const payload = {450          vaultBlob: "ZGF0YQ==",451          timestamp: Date.now(),452          verifyCode: "PX-TEST-1234",453          vaultFingerprint: "ABCDEF1234567890",454          entryCount: 0,455          masterPassphrase: "secret",456          ...overrides,457        };458        const r = await dispatch({ type: "RESTORE_FROM_CODEX", payload });459        expect(r).toBeDefined();460        expect(r.ok).toBe(false);461      });462    }463464    it("null payload does not crash", async () => {465      const r = await dispatch({466        type: "RESTORE_FROM_CODEX",467        payload: null,468      } as unknown as Message);469      expect(r).toBeDefined();470      expect(r.ok).toBe(false);471    });472  });473474  // ── COPY handler ────────────────────────────────────────────────────────475476  describe("COPY handler adversarial", () => {477    for (const text of ["", "x", "a".repeat(10000), "\x00\x00\x00", "🔑"]) {478      it(`COPY of ${JSON.stringify(text.slice(0, 20))} resolves`, async () => {479        const r = await dispatch({480          type: "COPY",481          payload: { text, label: "test", alreadyWritten: true },482        });483        expect(r).toBeDefined();484      });485    }486  });487488  // ── FIRE_CANARY_PING adversarial ────────────────────────────────────────489490  describe("FIRE_CANARY_PING webhook url validation", () => {491    const badUrls = [492      "javascript:alert(1)",493      "file:///etc/passwd",494      "data:text/plain,evil",495      "",496      "not a url",497      "ftp://example.com/hook",498    ];499    for (const url of badUrls) {500      it(`rejects bad webhook URL ${JSON.stringify(url)}`, async () => {501        const r = await dispatch({502          type: "FIRE_CANARY_PING",503          payload: {504            webhookUrl: url,505            entryTitle: "x",506            trigger: "test",507          },508        });509        expect(r.ok).toBe(false);510      });511    }512  });513514  // ── GET_ENTRY_FOR_FILL safety ───────────────────────────────────────────515516  describe("GET_ENTRY_FOR_FILL safety", () => {517    it("returns entry without passkey field for login entry", async () => {518      const vault = await import("./vault");519      await vault.createVault("test-pass-12345");520      const e = await vault.addEntry({521        title: "GitHub",522        username: "june",523        password: "x",524        url: "https://github.com",525        type: "login",526      });527      const r = await dispatch({528        type: "GET_ENTRY_FOR_FILL",529        payload: { id: e.id },530      });531      expect(r.ok).toBe(true);532      const data = (r as { data: Record<string, unknown> }).data;533      expect("passkey" in data).toBe(false);534      expect("privateKeyJwk" in data).toBe(false);535    });536537    it("missing id returns error", async () => {538      await setupUnlockedVault();539      const r = await dispatch({540        type: "GET_ENTRY_FOR_FILL",541        payload: { id: "not-a-real-id" },542      });543      expect(r.ok).toBe(false);544    });545546    it("locked vault returns error", async () => {547      const vault = await import("./vault");548      await vault.createVault("test-pass-12345");549      vault.lock();550      const r = await dispatch({551        type: "GET_ENTRY_FOR_FILL",552        payload: { id: "x" },553      });554      expect(r.ok).toBe(false);555    });556  });557558  // ── ADD_ENTRY adversarial when locked ───────────────────────────────────559560  describe("ADD_ENTRY locked-state safety", () => {561    it("locked vault: ADD_ENTRY returns vault locked", async () => {562      const r = await dispatch({563        type: "ADD_ENTRY",564        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },565      });566      expect(r.ok).toBe(false);567    });568569    it("ADD_ENTRY with no password and no passkey returns validation error", async () => {570      await setupUnlockedVault();571      const r = await dispatch({572        type: "ADD_ENTRY",573        payload: { title: "x", username: "u", password: "", url: "https://e.com" },574      });575      expect(r.ok).toBe(false);576    });577  });578579  // ── DELETE_ENTRY adversarial ────────────────────────────────────────────580581  describe("DELETE_ENTRY adversarial", () => {582    const badIds = [583      "",584      "   ",585      "not-a-uuid",586      String("a").repeat(10000),587      "00000000-0000-0000-0000-000000000000",588      "<script>",589      "\x00",590    ];591    for (const id of badIds) {592      it(`unknown id ${JSON.stringify(id.slice(0, 30))} returns error`, async () => {593        await setupUnlockedVault();594        const r = await dispatch({595          type: "DELETE_ENTRY",596          payload: { id },597        });598        expect(r.ok).toBe(false);599      });600    }601  });602603  // ── BATCH_ADD_ENTRIES adversarial ───────────────────────────────────────604605  describe("BATCH_ADD_ENTRIES adversarial", () => {606    it("empty array does not crash", async () => {607      await setupUnlockedVault();608      const r = await dispatch({609        type: "BATCH_ADD_ENTRIES",610        payload: { entries: [] },611      });612      expect(r).toBeDefined();613    });614615    it("non-array entries does not crash", async () => {616      await setupUnlockedVault();617      const r = await dispatch({618        type: "BATCH_ADD_ENTRIES",619        payload: { entries: "not-an-array" as unknown as unknown[] },620      });621      expect(r).toBeDefined();622    });623624    it("mostly-malformed entries does not crash", async () => {625      await setupUnlockedVault();626      const r = await dispatch({627        type: "BATCH_ADD_ENTRIES",628        payload: {629          entries: [630            null,631            undefined,632            42,633            "string",634            { title: "ok", username: "u", password: "p", url: "https://e.com" },635          ] as unknown as unknown[],636        },637      });638      expect(r).toBeDefined();639    });640  });641642  // ── SEARCH_VAULT adversarial ────────────────────────────────────────────643644  describe("SEARCH_VAULT adversarial", () => {645    const queries = [646      "",647      " ",648      "x",649      "a".repeat(1000),650      "🔑",651      "<script>",652      "' OR '1'='1",653      "../../",654      "\x00",655    ];656    for (const query of queries) {657      it(`query ${JSON.stringify(query.slice(0, 30))} does not crash`, async () => {658        await setupUnlockedVault();659        const r = await dispatch({660          type: "SEARCH_VAULT",661          payload: { query },662        });663        expect(r).toBeDefined();664      });665    }666  });667668  // ── UPDATE_SETTINGS adversarial ─────────────────────────────────────────669670  describe("UPDATE_SETTINGS adversarial", () => {671    const patches = [672      { name: "empty", patch: {} },673      { name: "junk keys", patch: { __proto__: { polluted: true } } },674      { name: "very long autolock", patch: { autoLockMinutes: 99999999 } },675      { name: "negative autolock", patch: { autoLockMinutes: -5 } },676      { name: "string autolock", patch: { autoLockMinutes: "five" } },677      { name: "unknown nested", patch: { foo: { bar: { baz: 1 } } } },678    ];679    for (const { name, patch } of patches) {680      it(`patch ${name} does not crash`, async () => {681        await setupUnlockedVault();682        const r = await dispatch({683          type: "UPDATE_SETTINGS",684          payload: patch as Record<string, unknown>,685        });686        expect(r).toBeDefined();687      });688    }689690    it("prototype pollution check: Object.prototype.polluted stays unset", async () => {691      await setupUnlockedVault();692      await dispatch({693        type: "UPDATE_SETTINGS",694        payload: JSON.parse(695          '{"__proto__": {"polluted": "yes"}}'696        ) as Record<string, unknown>,697      });698      // eslint-disable-next-line @typescript-eslint/no-explicit-any699      expect(({} as any).polluted).toBeUndefined();700    });701  });702703  // ── Wider adversarial payload matrix per handler ────────────────────────704705  describe("wide adversarial payload matrix", () => {706    const adversarialPayloads: Array<{ name: string; payload: unknown }> = [707      { name: "true", payload: true },708      { name: "false", payload: false },709      { name: "NaN", payload: NaN },710      { name: "Infinity", payload: Infinity },711      { name: "-Infinity", payload: -Infinity },712      { name: "BigInt-like", payload: { type: "bigint" } },713      { name: "Date", payload: new Date() },714      { name: "RegExp", payload: /pattern/ },715      { name: "Symbol-string", payload: Symbol("x").toString() },716      { name: "circular reference", payload: (() => {717        const o: Record<string, unknown> = {};718        o.self = o;719        return o;720      })() },721    ];722723    const fastHandlers = [724      "ADD_ENTRY",725      "UPDATE_ENTRY",726      "DELETE_ENTRY",727      "AUTOFILL_QUERY",728      "GET_ENTRY_FOR_FILL",729      "UPDATE_SETTINGS",730      "SEARCH_VAULT",731      "SAVE_NOTE",732      "DELETE_NOTE",733      "PURGE_ENTRY",734      "RESTORE_ENTRY",735      "PURGE_EXPIRED_TRASH",736      "ADD_ADDITIONAL_URL",737      "BATCH_ADD_ENTRIES",738      "RESTORE_ENTRIES",739      "ACTIVATE_LICENSE",740      "APPROVE_PASSKEY",741      "DENY_PASSKEY",742      "SET_PASSKEY_PROVIDER_ENABLED",743      "REMOVE_PAIRED_DEVICE",744      "RESTORE_VAULT_SNAPSHOT",745      "RESPOND_INCOMING_PAIRING",746      "VERIFY_MAIN_PASSWORD",747      "SETUP_DURESS",748    ];749750    for (const handler of fastHandlers) {751      for (const { name, payload } of adversarialPayloads) {752        it(`${handler} + ${name} payload resolves without crash`, async () => {753          const r = await dispatch({ type: handler, payload } as unknown as Message);754          expect(r).toBeDefined();755        });756      }757    }758  });759760  // ── Foreign sender × handler matrix ─────────────────────────────────────761762  describe("foreign sender rejection matrix", () => {763    const safeQuickHandlers = [764      "GET_ENTRIES",765      "IS_UNLOCKED",766      "HAS_VAULT",767      "GET_VAULT_STATUS",768      "GET_SETTINGS",769      "GET_DELETED_ENTRIES",770      "COUNT_DUPLICATES",771      "GET_VAULT_HISTORY",772      "ADD_ENTRY",773      "DELETE_ENTRY",774      "AUTOFILL_QUERY",775      "GET_ENTRY_FOR_FILL",776      "SAVE_NOTE",777      "DELETE_NOTE",778      "GET_NOTES",779      "SEARCH_VAULT",780      "GET_LICENSE",781      "GET_LICENSE_KEY",782      "GET_RECOVERY_WORDS",783      "VALIDATE_RECOVERY_WORDS",784      "RECOVER_WITH_WORDS",785      "RESTORE_FROM_CODEX",786      "GET_CODEX_VAULT_DATA",787      "ESTIMATE_CODEX_PAGES",788    ];789    for (const handler of safeQuickHandlers) {790      it(`${handler} from foreign sender returns invalid sender`, async () => {791        if (!listener) throw new Error("listener not registered");792        const r = await new Promise<MessageResponse>((resolve) => {793          listener!(794            { type: handler, payload: {} } as Message,795            { id: "foreign-extension-id" } as chrome.runtime.MessageSender,796            resolve797          );798        });799        expect(r.ok).toBe(false);800        expect(r.error).toBe("invalid_sender");801      });802    }803  });804805  // ── ADD_ENTRY payload-shape robustness ──────────────────────────────────806807  describe("ADD_ENTRY payload shape robustness", () => {808    const shapes = [809      { name: "no title", body: { username: "u", password: "p", url: "https://e.com" } },810      { name: "no url", body: { title: "t", username: "u", password: "p" } },811      { name: "title only", body: { title: "t", password: "p" } },812      { name: "spurious type", body: { type: "login", title: "t", password: "p" } },813      { name: "unknown type", body: { type: "wat", title: "t", password: "p" } },814      { name: "with extra field", body: { title: "t", password: "p", extra: "ignored" } },815      { name: "url with port", body: { title: "t", password: "p", url: "https://e.com:8080" } },816      { name: "url with path", body: { title: "t", password: "p", url: "https://e.com/a/b/c" } },817      { name: "username as number", body: { title: "t", username: 42, password: "p", url: "https://e.com" } },818      { name: "password as number", body: { title: "t", password: 12345, url: "https://e.com" } },819      { name: "title as null", body: { title: null, password: "p", url: "https://e.com" } },820      { name: "title as object", body: { title: { wat: 1 }, password: "p", url: "https://e.com" } },821    ];822    for (const { name, body } of shapes) {823      it(`ADD_ENTRY shape ${name} resolves without crash`, async () => {824        await setupUnlockedVault();825        const r = await dispatch({826          type: "ADD_ENTRY",827          payload: body as Parameters<typeof dispatch>[0]["payload"],828        } as Message);829        expect(r).toBeDefined();830      });831    }832  });833834  // ── UPDATE_ENTRY patch-shape robustness ─────────────────────────────────835836  describe("UPDATE_ENTRY patch shape robustness", () => {837    const patches = [838      { name: "title only", patch: { title: "New" } },839      { name: "password only", patch: { password: "newpass" } },840      { name: "url only", patch: { url: "https://e2.com" } },841      { name: "many fields", patch: { title: "N", username: "u2", password: "p2", url: "https://e2.com" } },842      { name: "unknown field", patch: { totallyMadeUp: "x" } },843      { name: "deletedAt forbidden", patch: { deletedAt: Date.now() } },844      { name: "createdAt forbidden", patch: { createdAt: 0 } },845      { name: "modifiedAt patch", patch: { modifiedAt: Date.now() } },846      { name: "id patch (should reject or ignore)", patch: { id: "new-id" } },847    ];848    for (const { name, patch } of patches) {849      it(`UPDATE_ENTRY patch ${name} resolves without crash`, async () => {850        const vault = await import("./vault");851        await vault.createVault("test-pass-12345");852        const e = await vault.addEntry({853          title: "x",854          username: "u",855          password: "p",856          url: "https://e.com",857          type: "login",858        });859        const r = await dispatch({860          type: "UPDATE_ENTRY",861          payload: { id: e.id, patch },862        });863        expect(r).toBeDefined();864      });865    }866  });867868  // ── Sequence: vault life-cycle adversarial ──────────────────────────────869870  describe("vault lifecycle adversarial sequences", () => {871    for (let i = 0; i < 20; i++) {872      it(`random message sequence #${i} does not crash`, async () => {873        const vault = await import("./vault");874        await vault.createVault("test-pass-12345");875        const handlers = [876          { type: "GET_ENTRIES" },877          { type: "IS_UNLOCKED" },878          { type: "GET_VAULT_STATUS" },879          { type: "HAS_VAULT" },880          { type: "GET_DELETED_ENTRIES" },881          { type: "GET_SETTINGS" },882          { type: "COUNT_DUPLICATES" },883          { type: "GET_VAULT_HISTORY" },884        ] as Array<Message>;885        for (const h of handlers) {886          const r = await dispatch(h);887          expect(r).toBeDefined();888        }889      });890    }891  });892893  // ── Read-only handlers when locked ──────────────────────────────────────894895  describe("read-only handlers when locked", () => {896    const safeWhenLocked = [897      "IS_UNLOCKED",898      "HAS_VAULT",899      "GET_VAULT_STATUS",900      "GET_SETTINGS",901    ];902    for (const type of safeWhenLocked) {903      it(`${type} works without crash when locked`, async () => {904        const r = await dispatch({ type } as unknown as Message);905        expect(r).toBeDefined();906      });907    }908909    const gatedWhenLocked = [910      "GET_ENTRIES",911      "GET_DELETED_ENTRIES",912      "COUNT_DUPLICATES",913      "GET_IDENTITIES",914      "GET_IDENTITY_SUGGESTIONS",915    ];916    for (const type of gatedWhenLocked) {917      it(`${type} returns ok=false when locked`, async () => {918        const r = await dispatch({ type } as unknown as Message);919        expect(r).toBeDefined();920        // Some handlers return ok:false explicitly; others may throw.921        if (r.ok === true) {922          // Accept ok:true only if data is empty.923          const data = (r as { data: unknown }).data;924          expect(925            data == null ||926              (Array.isArray(data) && data.length === 0) ||927              data === 0928          ).toBe(true);929        } else {930          expect(r.ok).toBe(false);931        }932      });933    }934  });935936  // ── M2/QUA-002: payload validation at trust boundary ────────────────────937938  describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {939    // Each entry: handler type + payloads that must produce ok:false.940    // The accepted error codes include 'invalid_payload' (the new validator)941    // or 'vault_locked' (handlers that lock-check before validating, which942    // is still a correct rejection of the request).943    const cases: Array<{ type: string; bad: unknown[] }> = [944      {945        type: "ADD_ENTRY",946        bad: [null, "string", 42, [], true],947      },948      {949        type: "BATCH_ADD_ENTRIES",950        bad: [null, { entries: "not-array" }, { entries: null }, { entries: 42 }, []],951      },952      {953        type: "UPDATE_ENTRY",954        bad: [null, {}, { id: 123 }, { id: "x" }, { id: "x", patch: "no" }, { id: "x", patch: null }, []],955      },956      {957        type: "RESTORE_ENTRIES",958        bad: [null, {}, { entries: "no" }, { entries: [], mode: "wrong" }, []],959      },960      {961        type: "UPDATE_SETTINGS",962        bad: [null, "string", 42, []],963      },964      {965        type: "REMOVE_TRUSTED_NETWORK",966        bad: [null, "string", 42, []],967      },968    ];969970    for (const { type, bad } of cases) {971      for (const payload of bad) {972        const label = JSON.stringify(payload);973        it(`${type} rejects malformed payload: ${label}`, async () => {974          await setupUnlockedVault();975          const r = await dispatch({ type, payload } as unknown as Message);976          expect(r.ok).toBe(false);977          const err = (r as { error?: string }).error ?? "";978          expect(err).toMatch(/invalid_payload|vault[_ ]locked/);979        });980      }981    }982  });983984  // ── AUD-002: payload validation for previously raw-cast handlers ────────985986  describe("AUD-002 payload validation", () => {987    describe("SEARCH_VAULT", () => {988      const badQueries: Array<[string, unknown]> = [989        ["null", null],990        ["numeric", 42],991        ["boolean", true],992        ["object", { evil: true }],993        ["array", []],994        ["missing", undefined],995      ];996      for (const [name, query] of badQueries) {997        it(`rejects ${name} query`, async () => {998          await setupUnlockedVault();999          const r = await dispatch({1000            type: "SEARCH_VAULT",1001            payload: { query },1002          } as unknown as Message);1003          expect(r.ok).toBe(false);1004          expect((r as { error?: string }).error).toBe("invalid_payload");1005        });1006      }10071008      it("accepts valid string query", async () => {1009        await setupUnlockedVault();1010        const r = await dispatch({1011          type: "SEARCH_VAULT",1012          payload: { query: "github" },1013        });1014        expect(r.ok).toBe(true);1015      });1016    });10171018    describe("COPY", () => {1019      const bads: Array<[string, unknown, unknown]> = [1020        ["null text", null, "pw"],1021        ["numeric text", 42, "pw"],1022        ["object text", { evil: true }, "pw"],1023        ["missing text", undefined, "pw"],1024        ["null label", "secret", null],1025        ["numeric label", "secret", 42],1026      ];1027      for (const [name, text, label] of bads) {1028        it(`rejects ${name}`, async () => {1029          await setupUnlockedVault();1030          const r = await dispatch({1031            type: "COPY",1032            payload: { text, label },1033          } as unknown as Message);1034          expect(r.ok).toBe(false);1035          expect((r as { error?: string }).error).toBe("invalid_payload");1036        });1037      }1038    });10391040    describe("ML_PREDICT_BATCH", () => {1041      const bads: Array<[string, unknown]> = [1042        ["null", null],1043        ["object", { not: "array" }],1044        ["string", "features"],1045        ["number", 42],1046        ["missing", undefined],1047      ];1048      for (const [name, featuresArray] of bads) {1049        it(`rejects ${name} featuresArray`, async () => {1050          const r = await dispatch({1051            type: "ML_PREDICT_BATCH",1052            payload: { featuresArray },1053          } as unknown as Message);1054          expect(r.ok).toBe(false);1055          expect((r as { error?: string }).error).toBe("invalid_payload");1056        });1057      }10581059      it("accepts empty array featuresArray", async () => {1060        const r = await dispatch({1061          type: "ML_PREDICT_BATCH",1062          payload: { featuresArray: [] },1063        } as unknown as Message);1064        expect((r as { error?: string }).error).not.toBe("invalid_payload");1065      });1066    });1067  });1068});