1234567891011121314import { 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;242526const 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 699 expect(({} as any).polluted).toBeUndefined();700 });701 });702703 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 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 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 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 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 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 921 if (r.ok === true) {922 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 });935});