1import { beforeEach, describe, expect, it, vi } from "vitest";23import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";4import type { Message, MessageResponse } from "../shared/types";56type StorageShape = Record<string, unknown>;7type MessageListener = (8 msg: unknown,9 sender: chrome.runtime.MessageSender,10 sendResponse: (response: MessageResponse) => void11) => boolean;1213describe("background messages", () => {14 let storage: StorageShape;15 let sessionStorage: StorageShape;16 let listener: MessageListener;17 let fetchMock: ReturnType<typeof vi.fn>;1819 beforeEach(async () => {20 vi.resetModules();21 vi.useRealTimers();22 storage = {23 peach_settings: { breachMonitoring: false },24 };25 sessionStorage = {};2627 fetchMock = vi.fn(async () => ({28 ok: true,29 status: 200,30 text: async () => "",31 }));32 vi.stubGlobal("fetch", fetchMock);33 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);3435 const tabs = [{ id: 1 }];36 const chromeMock = {37 runtime: {38 id: "peach-extension",39 getURL: (p: string) => `chrome-extension://peach-extension/${p}`,40 onMessage: {41 addListener: vi.fn((fn: MessageListener) => {42 listener = fn;43 }),44 },45 sendMessage: vi.fn(async () => ({ ok: true })),46 },47 tabs: {48 query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {49 if (typeof callback === "function") {50 callback(tabs);51 return;52 }53 return Promise.resolve(tabs);54 }),55 sendMessage: vi.fn(async () => ({ ok: true })),56 },57 storage: {58 local: {59 get: vi.fn(async (key?: string | string[]) => {60 if (!key) return storage;61 if (typeof key === "string") {62 return { [key]: storage[key] };63 }6465 return key.reduce<Record<string, unknown>>((result, entry) => {66 result[entry] = storage[entry];67 return result;68 }, {});69 }),70 set: vi.fn(async (next: Record<string, unknown>) => {71 Object.assign(storage, next);72 }),73 remove: vi.fn(async (keys: string | string[]) => {74 const list = Array.isArray(keys) ? keys : [keys];75 for (const key of list) {76 delete storage[key];77 }78 }),79 clear: vi.fn(async () => {80 for (const key of Object.keys(storage)) delete storage[key];81 }),82 },83 session: {84 get: vi.fn(async (key?: string | string[]) => {85 if (!key) return sessionStorage;86 if (typeof key === "string") {87 return { [key]: sessionStorage[key] };88 }89 return key.reduce<Record<string, unknown>>((result, entry) => {90 result[entry] = sessionStorage[entry];91 return result;92 }, {});93 }),94 set: vi.fn(async (next: Record<string, unknown>) => {95 Object.assign(sessionStorage, next);96 }),97 remove: vi.fn(async (keys: string | string[]) => {98 const list = Array.isArray(keys) ? keys : [keys];99 for (const key of list) {100 delete sessionStorage[key];101 }102 }),103 clear: vi.fn(async () => {104 for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];105 }),106 },107 sync: {108 get: vi.fn(async (_key?: string | string[]) => ({})),109 set: vi.fn(async (_values: Record<string, unknown>) => undefined),110 remove: vi.fn(async (_keys: string | string[]) => undefined),111 clear: vi.fn(async () => undefined),112 },113 },114 alarms: {115 clear: vi.fn(),116 create: vi.fn(),117 onAlarm: { addListener: vi.fn() },118 },119 idle: {120 setDetectionInterval: vi.fn(),121 onStateChanged: { addListener: vi.fn() },122 },123 scripting: {124 executeScript: vi.fn(async () => []),125 },126 } as unknown as typeof chrome;127128 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;129130 const messages = await import("./messages");131 messages.setupMessageHandler();132 const vault = await import("./vault");133 await vault.ensureSettingsLoaded();134 });135136 async function dispatch(137 message: Message,138 sender: chrome.runtime.MessageSender = {139 id: "peach-extension",140 url: "chrome-extension://peach-extension/popup.html",141 }142 ): Promise<MessageResponse> {143 return await new Promise<MessageResponse>((resolve) => {144 const keepChannelOpen = listener(message, sender, (response) => resolve(response));145 expect(keepChannelOpen).toBe(true);146 });147 }148149 it("GET_ENTRIES when unlocked returns the entry array", async () => {150 const vault = await import("./vault");151 await vault.createVault("correct-pass-12345");152 await vault.addEntry({153 title: "GitHub",154 username: "june",155 password: "secret123",156 url: "https://github.com",157 });158159 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({160 ok: true,161 data: [162 expect.objectContaining({163 title: "GitHub",164 username: "june",165 }),166 ],167 });168 });169170 it("GET_ENTRIES when locked returns a vault locked error", async () => {171 const vault = await import("./vault");172 await vault.createVault("correct-pass-12345");173 vault.lock();174175 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({176 ok: false,177 error: "vault_locked",178 });179 });180181 it("ADD_ENTRY with a valid payload returns the created entry", async () => {182 const vault = await import("./vault");183 await vault.createVault("correct-pass-12345");184185 const response = await dispatch({186 type: "ADD_ENTRY",187 payload: {188 title: "GitHub",189 username: "june",190 password: "secret123",191 url: "https://github.com",192 },193 });194195 expect(response).toMatchObject({196 ok: true,197 data: expect.objectContaining({198 id: expect.any(String),199 title: "GitHub",200 }),201 });202 expect(vault.getEntries()).toHaveLength(1);203 });204205 it("ADD_ENTRY when locked returns a vault locked error", async () => {206 const vault = await import("./vault");207 await vault.createVault("correct-pass-12345");208 vault.lock();209210 await expect(211 dispatch({212 type: "ADD_ENTRY",213 payload: {214 title: "GitHub",215 username: "june",216 password: "secret123",217 url: "https://github.com",218 },219 })220 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });221 });222223 it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {224 const vault = await import("./vault");225 await vault.createVault("correct-pass-12345");226227 await expect(228 dispatch({229 type: "ADD_ENTRY",230 payload: {231 title: "GitHub",232 username: "june",233 password: "",234 url: "https://github.com",235 },236 })237 ).resolves.toMatchObject({ ok: true });238 });239240 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {241 const vault = await import("./vault");242 await vault.createVault("correct-pass-12345");243 const entry = await vault.addEntry({244 title: "GitHub",245 username: "june",246 password: "secret123",247 url: "https://github.com",248 });249250 await expect(251 dispatch({252 type: "UPDATE_ENTRY",253 payload: { id: entry.id, patch: { password: "new-secret" } },254 })255 ).resolves.toMatchObject({ ok: true });256257 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");258 });259260 it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {261 const vault = await import("./vault");262 await vault.createVault("correct-pass-12345");263 const entry = await vault.addEntry({264 title: "GitHub",265 username: "june",266 password: "secret123",267 url: "https://github.com",268 });269270 await expect(271 dispatch({272 type: "UPDATE_ENTRY",273 payload: { id: entry.id, patch: { password: "" } },274 })275 ).resolves.toMatchObject({ ok: true });276277 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();278 });279280 it("DELETE_ENTRY soft-deletes the target entry", async () => {281 const vault = await import("./vault");282 await vault.createVault("correct-pass-12345");283 const entry = await vault.addEntry({284 title: "GitHub",285 username: "june",286 password: "secret123",287 url: "https://github.com",288 });289290 await expect(291 dispatch({292 type: "DELETE_ENTRY",293 payload: { id: entry.id },294 })295 ).resolves.toMatchObject({ ok: true });296297 expect(vault.getEntries()).toEqual([]);298 expect(vault.getDeletedEntries()).toHaveLength(1);299 });300301 it("DELETE_ENTRY of a missing id returns an error", async () => {302 const vault = await import("./vault");303 await vault.createVault("correct-pass-12345");304305 await expect(306 dispatch({307 type: "DELETE_ENTRY",308 payload: { id: "missing" },309 })310 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });311 });312313 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {314 const vault = await import("./vault");315 await vault.createVault("correct-pass-12345");316 const entry = await vault.addEntry({317 title: "GitHub",318 username: "june",319 password: "secret123",320 url: "https://github.com",321 });322323 const response = await dispatch(324 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },325 { id: "peach-extension", url: "https://github.com/login" },326 );327328 expect(response).toMatchObject({329 ok: true,330 data: expect.objectContaining({331 id: entry.id,332 username: "june",333 password: "secret123",334 }),335 });336 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();337 });338339 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {340 const vault = await import("./vault");341 await vault.createVault("correct-pass-12345");342 vault.lock();343344 await expect(345 dispatch({346 type: "GET_ENTRY_FOR_FILL",347 payload: { id: "missing" },348 })349 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });350 });351352 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {353 const vault = await import("./vault");354 await vault.createVault("correct-pass-12345");355 const generated = await generatePasskeyKeypair(-7);356 const entry = await vault.addEntry({357 type: "login",358 title: "WebAuthn.io",359 passkey: {360 rpId: "webauthn.io",361 rpName: "WebAuthn.io",362 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),363 publicKeyCose: base64urlEncode(generated.publicKeyCose),364 privateKeyJwk: generated.privateKeyJwk,365 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),366 userName: "june",367 userDisplayName: "June",368 algorithm: -7,369 signCount: 0,370 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",371 backupEligible: true,372 backupState: false,373 createdAt: Date.now(),374 },375 });376377 await expect(378 dispatch({379 type: "GET_ENTRY_FOR_FILL",380 payload: { id: entry.id },381 })382 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });383 });384385 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {386 const vault = await import("./vault");387 await vault.createVault("correct-pass-12345");388 await vault.addEntry({389 title: "GitHub",390 username: "june",391 password: "secret123",392 url: "https://github.com",393 totp: "JBSWY3DPEHPK3PXP",394 });395396 const response = await dispatch({397 type: "AUTOFILL_QUERY",398 payload: { domain: "github.com" },399 });400401 expect(response).toMatchObject({402 ok: true,403 data: [404 {405 id: expect.any(String),406 title: "GitHub",407 username: "june",408 url: "https://github.com",409 hasTotp: true,410 },411 ],412 });413 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();414 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();415 });416417 it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {418 const vault = await import("./vault");419 await vault.createVault("correct-pass-12345");420 await vault.addEntry({421 title: "GitHub",422 username: "june",423 password: "secret123",424 url: "https://github.com",425 });426 await vault.addEntry({427 type: "note",428 title: "Secure note",429 notes: "hello",430 });431432 const response = await dispatch({433 type: "AUTOFILL_QUERY",434 payload: { domain: "github.com", includeAll: true },435 });436437 expect(response).toMatchObject({438 ok: true,439 data: [440 {441 id: expect.any(String),442 title: "GitHub",443 username: "june",444 url: "https://github.com",445 hasPasskey: false,446 },447 ],448 });449 const data = response.data as Array<Record<string, unknown>>;450 expect(data).toHaveLength(1);451 452 expect(data[0]).not.toHaveProperty("password");453 expect(data[0]).not.toHaveProperty("totp");454 });455456 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {457 const vault = await import("./vault");458 await vault.createVault("correct-pass-12345");459 await vault.addEntry({460 title: "GitHub",461 username: "june",462 password: "secret123",463 url: "https://github.com",464 totp: "JBSWY3DPEHPK3PXP",465 });466 await vault.addEntry({467 title: "No 2FA",468 username: "person",469 password: "secret123",470 url: "https://github.com",471 });472473 const response = await dispatch({474 type: "AUTOFILL_QUERY",475 payload: { domain: "github.com", kind: "totp" },476 });477478 expect(response).toMatchObject({479 ok: true,480 data: [481 {482 id: expect.any(String),483 title: "GitHub",484 username: "june",485 url: "https://github.com",486 hasTotp: true,487 },488 ],489 });490 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();491 expect(response.data).toHaveLength(1);492 });493494 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {495 const vault = await import("./vault");496 await vault.createVault("correct-pass-12345");497 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });498499 const response = await dispatch({500 type: "RESTORE_ENTRIES",501 payload: {502 mode: "replace",503 entries: [504 {505 id: "restored-1",506 type: "login",507 title: "B",508 username: "june",509 password: "two",510 url: "https://b.test",511 createdAt: 1,512 modifiedAt: 1,513 },514 ],515 },516 });517518 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });519 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);520 });521522 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {523 const vault = await import("./vault");524 await vault.createVault("correct-pass-12345");525526 await expect(527 dispatch({528 type: "RESTORE_ENTRIES",529 payload: { mode: "replace", entries: [] },530 })531 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });532 });533534 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {535 const vault = await import("./vault");536 await vault.createVault("correct-pass-12345");537538 await expect(539 dispatch({540 type: "CHANGE_PASSWORD",541 payload: {542 currentPassword: "correct-pass-12345",543 newPassword: "new-pass-67890",544 },545 })546 ).resolves.toMatchObject({ ok: true });547548 vault.lock();549 expect(await vault.unlock("new-pass-67890")).toBe(true);550551 await expect(552 dispatch({553 type: "CHANGE_PASSWORD",554 payload: {555 currentPassword: "wrong-pass",556 newPassword: "another-pass",557 },558 })559 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });560 });561562 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {563 const vault = await import("./vault");564 await vault.createVault("correct-pass-12345");565566 (567 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>568 ).mockClear();569 (570 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>571 ).mockClear();572573 await expect(574 dispatch({575 type: "UPDATE_SETTINGS",576 payload: { autoLockMinutes: 30 },577 })578 ).resolves.toMatchObject({ ok: true });579580 expect(vault.getSettings().autoLockMinutes).toBe(30);581 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);582 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {583 delayInMinutes: 30,584 });585 });586587 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {588 vi.useFakeTimers();589 const vault = await import("./vault");590 await vault.createVault("correct-pass-12345");591 const entry = await vault.addEntry({592 title: "GitHub",593 username: "june",594 password: "secret123",595 url: "https://github.com",596 });597 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);598599 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });600 await vi.runAllTimersAsync();601 const response = await responsePromise;602603 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });604 expect(vault.getEntryById(entry.id)?.breach).toBe(7);605 breachSpy.mockRestore();606 });607608 it("messages from foreign or URL-less senders are rejected", async () => {609 const vault = await import("./vault");610 await vault.createVault("correct-pass-12345");611612 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({613 ok: false,614 error: "invalid_sender",615 });616617 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({618 ok: false,619 error: "invalid_sender",620 });621 });622623 it("LOCK and UNLOCK messages update vault state correctly", async () => {624 const vault = await import("./vault");625626 await expect(627 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })628 ).resolves.toMatchObject({ ok: true });629 expect(vault.isUnlocked()).toBe(true);630631 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });632 expect(vault.isUnlocked()).toBe(false);633634 await expect(635 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })636 ).resolves.toMatchObject({ ok: false });637 expect(vault.isUnlocked()).toBe(false);638639 await expect(640 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })641 ).resolves.toMatchObject({ ok: true });642 expect(vault.isUnlocked()).toBe(true);643 });644645 it("remembers the last successful unlock method", async () => {646 const vault = await import("./vault");647 const password = "correct-pass-12345";648 const popupSender = {649 id: "peach-extension",650 url: "chrome-extension://peach-extension/src/popup/index.html",651 } as chrome.runtime.MessageSender;652653 await vault.createVault(password);654 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);655 vault.lock();656657 await expect(658 dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),659 ).resolves.toMatchObject({ ok: true });660 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);661662 vault.lock();663 await expect(664 dispatch({ type: "UNLOCK", payload: { password } }, popupSender),665 ).resolves.toMatchObject({ ok: true });666 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);667 });668669 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {670 const response = await dispatch({671 type: "FIRE_CANARY_PING",672 payload: {673 webhookUrl: "https://example.com/hook",674 entryTitle: "GitHub",675 trigger: "password_copied",676 },677 });678679 expect(response).toMatchObject({ ok: true });680 expect(fetchMock).toHaveBeenCalledWith(681 "https://example.com/hook",682 expect.objectContaining({683 method: "POST",684 headers: { "Content-Type": "application/json" },685 body: expect.any(String),686 })687 );688 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;689 expect(body).toMatchObject({690 entry: "GitHub",691 trigger: "password_copied",692 source: "peach-extension",693 });694 });695696 it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {697 const payload = {698 webhookUrl: "https://example.com/hook",699 entryTitle: "GitHub",700 trigger: "autofilled" as const,701 };702 const first = await dispatch({ type: "FIRE_CANARY_PING", payload });703 expect(first).toMatchObject({ ok: true });704 expect(fetchMock).toHaveBeenCalledTimes(1);705706 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });707 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });708 expect(fetchMock).toHaveBeenCalledTimes(1); 709 });710711 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {712 await dispatch({713 type: "FIRE_CANARY_PING",714 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },715 });716 await dispatch({717 type: "FIRE_CANARY_PING",718 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },719 });720 expect(fetchMock).toHaveBeenCalledTimes(2);721 });722723 it("LV-3: test pings are exempt from the debounce", async () => {724 const payload = {725 webhookUrl: "https://example.com/hook",726 entryTitle: "GitHub",727 trigger: "test" as const,728 };729 await dispatch({ type: "FIRE_CANARY_PING", payload });730 await dispatch({ type: "FIRE_CANARY_PING", payload });731 expect(fetchMock).toHaveBeenCalledTimes(2);732 });733734 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {735 await expect(736 dispatch({737 type: "FIRE_CANARY_PING",738 payload: {739 webhookUrl: "http://example.com/hook",740 entryTitle: "GitHub",741 trigger: "password_copied",742 },743 })744 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });745746 expect(fetchMock).not.toHaveBeenCalled();747 });748749 it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {750 for (const webhookUrl of [751 "https://[::ffff:192.168.0.1]/hook",752 "https://[::ffff:c0a8:1]/hook",753 "https://[::ffff:10.0.0.1]/hook",754 "https://[::ffff:127.0.0.1]/hook",755 "https://[::ffff:169.254.1.1]/hook",756 "https://[::ffff:172.16.0.1]/hook",757 ]) {758 await expect(759 dispatch({760 type: "FIRE_CANARY_PING",761 payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },762 })763 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });764 }765766 expect(fetchMock).not.toHaveBeenCalled();767 });768769 it("GET_IDENTITIES when locked returns vault locked error", async () => {770 const vault = await import("./vault");771 await vault.createVault("correct-pass-12345");772 vault.lock();773 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({774 ok: false,775 error: "vault_locked",776 });777 });778779 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {780 const vault = await import("./vault");781 await vault.createVault("correct-pass-12345");782 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });783 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });784 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });785786 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };787 expect(resp.ok).toBe(true);788 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;789 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({790 type: "email",791 useCount: 2,792 });793 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({794 type: "username",795 useCount: 1,796 });797 798 const emailIdx = data.findIndex((d) => d.value === "[email protected]");799 const userIdx = data.findIndex((d) => d.value === "juni_patel");800 expect(emailIdx).toBeLessThan(userIdx);801 });802803 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {804 const vault = await import("./vault");805 await vault.createVault("correct-pass-12345");806 for (let i = 0; i < 3; i++) {807 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });808 }809810 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };811 const data = resp.data as Array<{ value: string; useCount: number }>;812 const hits = data.filter((d) => d.value === "[email protected]");813 expect(hits).toHaveLength(1);814 expect(hits[0]!.useCount).toBe(3);815 });816817 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {818 const vault = await import("./vault");819 await vault.createVault("correct-pass-12345");820 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;821822 823 alarmsCreate.mockClear();824 await dispatch({ type: "GET_ENTRIES" });825 expect(alarmsCreate).toHaveBeenCalled();826827 828 alarmsCreate.mockClear();829 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });830 expect(alarmsCreate).not.toHaveBeenCalled();831 });832833 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {834 const vault = await import("./vault");835 await vault.createVault("correct-pass-12345");836 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });837838 839 storage.peach_license = "PEACH-PRO-TEST-KEY";840 storage.peach_pro_celebrated = true;841842 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });843 expect(res).toMatchObject({ ok: true });844845 846 expect(storage.peach_vault_a).toBeUndefined();847 848 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");849 expect(storage.peach_pro_celebrated).toBe(true);850 });851852 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {853 const messages = await import("./messages");854 const vault = await import("./vault");855856 let resolveGate: () => void = () => {};857 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));858859 let settled = false;860 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });861862 863 864 await new Promise((r) => setTimeout(r, 20));865 expect(settled).toBe(false);866867 868 await vault.createVault("correct-pass-12345");869 resolveGate();870871 await expect(pending).resolves.toMatchObject({ ok: true, data: true });872 });873874 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {875 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });876 expect(res.ok).toBe(false);877 878 const has = await dispatch({ type: "HAS_VAULT" });879 expect((has as { data?: boolean }).data).toBe(false);880 });881882 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {883 const vault = await import("./vault");884 await vault.createVault("correct-pass-12345");885 const e = await vault.addEntry({886 title: "Mail",887 username: "june",888 password: "pw",889 url: "https://mail.test",890 totp: "JBSWY3DPEHPK3PXP",891 });892893 const res = await dispatch(894 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },895 { id: "peach-extension", url: "https://mail.test/login" },896 );897 expect(res.ok).toBe(true);898 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };899 expect(data.totp).toBeUndefined();900 expect(data.totpCode).toMatch(/^\d{6}$/);901 expect(data.totpRemaining).toBeGreaterThan(0);902 expect(data.totpRemaining).toBeLessThanOrEqual(30);903 });904905 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {906 const vault = await import("./vault");907 await vault.createVault("correct-pass-12345");908 const e = await vault.addEntry({909 title: "Bank",910 username: "june",911 password: "old-pw",912 url: "https://bank.test",913 });914 await vault.updateEntry(e.id, { password: "new-pw" });915916 const fill = await dispatch(917 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },918 { id: "peach-extension", url: "https://bank.test/login" },919 );920 expect(fill.ok).toBe(true);921 922 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();923924 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });925 expect(edit.ok).toBe(true);926 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];927 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);928 });929930 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {931 const vault = await import("./vault");932 await vault.createVault("correct-pass-12345");933 const e = await vault.addEntry({934 title: "Mail",935 username: "june",936 password: "pw",937 url: "https://mail.test",938 totp: "JBSWY3DPEHPK3PXP",939 });940941 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });942 expect(res.ok).toBe(true);943 const data = res.data as { totp?: string };944 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");945 });946947 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {948 const vault = await import("./vault");949 await vault.createVault("correct-pass-12345");950951 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });952 expect(res).toMatchObject({ ok: true });953954 expect(storage.peach_vault_a).toBeUndefined();955 expect(storage.peach_license).toBeUndefined();956 expect(storage.peach_pro_celebrated).toBeUndefined();957 });958959 960961 function tabSender(url: string): chrome.runtime.MessageSender {962 return { id: "peach-extension", tab: { id: 1, url } as chrome.tabs.Tab, url };963 }964965 it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {966 const vault = await import("./vault");967 const messages = await import("./messages");968 await vault.createVault("correct-pass-12345");969 const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });970 const sender = tabSender("https://evil.example.com/phish");971 const token = messages.createSummonCapability(1, "https://evil.example.com");972973 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);974 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });975976 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);977 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });978 const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);979 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });980 });981982 it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {983 const vault = await import("./vault");984 const messages = await import("./messages");985 await vault.createVault("correct-pass-12345");986 const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });987 const sender = tabSender("https://evil.example.com/login");988 const token = messages.createSummonCapability(1, "https://evil.example.com");989990 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);991 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });992 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);993 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });994995 const offDomainReplay = await dispatch(996 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },997 tabSender("https://github.com/login"),998 );999 expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1000 });10011002 it("PB-26-201: summon capability cannot fill a different entry", async () => {1003 const vault = await import("./vault");1004 const messages = await import("./messages");1005 await vault.createVault("correct-pass-12345");1006 const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1007 const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1008 const sender = tabSender("https://evil.example.com/phish");1009 const token = messages.createSummonCapability(1, "https://evil.example.com");1010 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1011 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1012 expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1013 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1014 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1015 expect(first.id).not.toBe(second.id);1016 });10171018 it("PB-26-201: summon capability rejects wrong operation and sender", async () => {1019 const vault = await import("./vault");1020 const messages = await import("./messages");1021 await vault.createVault("correct-pass-12345");1022 const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://github.com" });1023 const token = messages.createSummonCapability(1, "https://evil.example.com");1024 const wrongSender = tabSender("https://other.example.com/phish");1025 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, wrongSender);1026 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1027 });10281029 it("MV-1: tab request with a matching domain returns the entry", async () => {1030 const vault = await import("./vault");1031 await vault.createVault("correct-pass-12345");1032 const entry = await vault.addEntry({1033 title: "GitHub",1034 username: "june",1035 password: "secret123",1036 url: "https://github.com",1037 });10381039 const res = await dispatch(1040 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1041 tabSender("https://github.com/login"),1042 );1043 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1044 });10451046 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1047 const vault = await import("./vault");1048 await vault.createVault("correct-pass-12345");1049 const entry = await vault.addEntry({1050 title: "GitHub",1051 username: "june",1052 password: "secret123",1053 url: "https://github.com",1054 });10551056 const res = await dispatch(1057 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1058 tabSender("https://evil.example.com/phish"),1059 );1060 1061 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1062 });10631064 it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1065 const vault = await import("./vault");1066 await vault.createVault("correct-pass-12345");1067 const entry = await vault.addEntry({1068 title: "GitHub",1069 username: "june",1070 password: "secret123",1071 url: "https://github.com",1072 });10731074 const res = await dispatch(1075 {1076 type: "GET_ENTRY_FOR_FILL",1077 payload: { id: entry.id, explicitSummon: true },1078 },1079 tabSender("https://evil.example.com/phish"),1080 );1081 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1082 });10831084 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1085 const vault = await import("./vault");1086 await vault.createVault("correct-pass-12345");1087 const entry = await vault.addEntry({1088 title: "GitHub",1089 username: "june",1090 password: "secret123",1091 url: "https://github.com",1092 });10931094 const res = await dispatch(1095 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1096 tabSender("not a url"),1097 );1098 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1099 });11001101 11021103 async function seedTwoDomainVault() {1104 const vault = await import("./vault");1105 await vault.createVault("correct-pass-12345");1106 await vault.addEntry({1107 title: "GitHub",1108 username: "june",1109 password: "gh",1110 url: "https://github.com",1111 });1112 await vault.addEntry({1113 title: "Example",1114 username: "june",1115 password: "ex",1116 url: "https://example.com",1117 });1118 }11191120 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1121 await seedTwoDomainVault();11221123 const res = await dispatch({1124 type: "AUTOFILL_QUERY",1125 payload: { domain: "github.com", includeAll: true },1126 });1127 expect(res.ok).toBe(true);1128 const data = res.data as Array<{ title: string }>;1129 1130 expect(data).toHaveLength(1);1131 expect(data[0].title).toBe("GitHub");1132 });11331134 it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1135 await seedTwoDomainVault();11361137 const res = await dispatch({1138 type: "AUTOFILL_QUERY",1139 payload: { domain: "github.com", includeAll: true, userGesture: true },1140 });1141 expect(res.ok).toBe(true);1142 const data = res.data as Array<{ title: string }>;1143 expect(data.map((d) => d.title)).toEqual(["GitHub"]);1144 });11451146 11471148 async function seedPendingVouched(deviceId: string) {1149 await chrome.storage.local.set({1150 peach_pending_vouched_devices_v1: [{1151 deviceId,1152 deviceName: "New Pixel",1153 deviceType: "android",1154 longTermPublicKeyHex: "e".repeat(64),1155 signingPublicKeyHex: "a".repeat(64),1156 wsPort: 51234,1157 pairedAt: 1_700_000_000_020,1158 receivedAt: 1_700_000_000_050,1159 vouchedByDeviceId: "extension-issuer",1160 }],1161 });1162 }11631164 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1165 const vault = await import("./vault");1166 await vault.createVault("correct-pass-12345");1167 await seedPendingVouched("android-new");1168 const { findPairedDeviceById } = await import("./paired-devices");1169 const { listPendingVouchedDevices } = await import(1170 "./pending-vouched-devices"1171 );11721173 const res = await dispatch({1174 type: "CONFIRM_VOUCHED_DEVICE",1175 payload: { deviceId: "android-new" },1176 });1177 expect(res).toMatchObject({ ok: true });11781179 expect(await findPairedDeviceById("android-new")).toMatchObject({1180 deviceName: "New Pixel",1181 trustSource: "vouched",1182 longTermPublicKeyHex: "e".repeat(64),1183 });1184 expect(await listPendingVouchedDevices()).toHaveLength(0);1185 });11861187 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1188 const vault = await import("./vault");1189 await vault.createVault("correct-pass-12345");1190 await seedPendingVouched("android-relabelled");1191 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1192 await import("./paired-devices");1193 const { listPendingVouchedDevices } = await import(1194 "./pending-vouched-devices"1195 );11961197 1198 1199 await upsertPairedDevice({1200 deviceId: "android-old",1201 deviceName: "New Pixel",1202 deviceType: "android",1203 longTermPublicKeyHex: "e".repeat(64),1204 signingPublicKeyHex: "a".repeat(64),1205 pairedAt: 1_700_000_000_000,1206 trustSource: "vouched",1207 });1208 await revokeDeviceById("android-old", "self");12091210 const res = await dispatch({1211 type: "CONFIRM_VOUCHED_DEVICE",1212 payload: { deviceId: "android-relabelled" },1213 });1214 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1215 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1216 expect(await listPendingVouchedDevices()).toHaveLength(0);1217 });12181219 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1220 const vault = await import("./vault");1221 await vault.createVault("correct-pass-12345");1222 await seedPendingVouched("android-new");1223 const { findPairedDeviceById } = await import("./paired-devices");1224 const { listPendingVouchedDevices } = await import(1225 "./pending-vouched-devices"1226 );12271228 const res = await dispatch({1229 type: "REJECT_VOUCHED_DEVICE",1230 payload: { deviceId: "android-new" },1231 });1232 expect(res).toMatchObject({ ok: true });12331234 expect(await findPairedDeviceById("android-new")).toBeNull();1235 expect(await listPendingVouchedDevices()).toHaveLength(0);1236 });12371238 12391240 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1241 const vault = await import("./vault");1242 await vault.createVault("correct-pass-12345");1243 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1244 await import("./paired-devices");12451246 await upsertPairedDevice({1247 deviceId: "phone-1",1248 deviceName: "June's Phone",1249 deviceType: "android",1250 longTermPublicKeyHex: "a".repeat(64),1251 signingPublicKeyHex: "b".repeat(64),1252 pairedAt: 1_700_000_000_000,1253 trustSource: "direct",1254 });12551256 const res = await dispatch({1257 type: "UNPAIR_DEVICE",1258 payload: { deviceId: "phone-1" },1259 });1260 expect(res).toMatchObject({ ok: true });12611262 1263 expect(await findPairedDeviceById("phone-1")).toBeNull();1264 1265 1266 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1267 const records = await listDeviceRevocations();1268 expect(records).toHaveLength(1);1269 expect(records[0]).toMatchObject({1270 deviceId: "phone-1",1271 longTermPublicKeyHex: "a".repeat(64),1272 revokedByDeviceId: expect.any(String),1273 });1274 });12751276 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1277 const vault = await import("./vault");1278 await vault.createVault("correct-pass-12345");1279 const { listDeviceRevocations } = await import("./paired-devices");12801281 const res = await dispatch({1282 type: "UNPAIR_DEVICE",1283 payload: { deviceId: "never-paired" },1284 });1285 expect(res).toMatchObject({ ok: true });1286 1287 1288 1289 1290 const records = await listDeviceRevocations();1291 expect(records).toHaveLength(1);1292 expect(records[0]).toMatchObject({1293 deviceId: "never-paired",1294 longTermPublicKeyHex: "0".repeat(64),1295 });1296 });12971298 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1299 const res = await dispatch({1300 type: "UNPAIR_DEVICE",1301 payload: { deviceId: "" },1302 });1303 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1304 });13051306 1307 1308 1309 1310 1311 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1312 const vault = await import("./vault");1313 await vault.createVault("correct-pass-12345");1314 const contentScriptSender = {1315 id: "peach-extension",1316 url: "https://evil.example/page",1317 };1318 const extensionPageSender = {1319 id: "peach-extension",1320 url: "chrome-extension://peach-extension/popup.html",1321 };1322 const completion = { requestId: 1, rpId: "evil.example" };13231324 1325 await expect(1326 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1327 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });13281329 1330 1331 await expect(1332 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1333 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1334 });13351336 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1337 const vault = await import("./vault");1338 await vault.createVault("correct-pass-12345");1339 const extensionPageSender = {1340 id: "peach-extension",1341 url: "chrome-extension://peach-extension/popup.html",1342 };1343 const completion = {1344 requestId: 123,1345 rpId: "example.com",1346 rpName: "Example",1347 userName: "june",1348 userHandle: "BBBB",1349 credentialId: "cred-1",1350 publicKeyCose: "c3BraQ",1351 algorithm: -7 as const,1352 };13531354 1355 await expect(1356 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1357 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });13581359 1360 sessionStorage.passkey_pending_firefox = {1361 requestId: 123,1362 type: "create",1363 rpId: "example.com",1364 rpName: "Example",1365 userName: "june",1366 browser: "firefox",1367 challenge: "AAAA",1368 userHandle: "BBBB",1369 };13701371 1372 await expect(1373 dispatch(1374 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1375 extensionPageSender1376 )1377 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });13781379 1380 await expect(1381 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1382 ).resolves.toMatchObject({ ok: true });1383 const entries = await dispatch({ type: "GET_ENTRIES" });1384 expect(entries).toMatchObject({ ok: true });1385 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1386 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1387 });13881389 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1390 await expect(1391 dispatch(1392 {1393 type: "INITIATE_FIREFOX_PASSKEY_CREATE",1394 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1395 },1396 { id: "peach-extension", url: "https://evil.example/page" }1397 )1398 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1399 });14001401 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1402 1403 1404 1405 1406 const vault = await import("./vault");1407 await vault.createVault("correct-pass-12345");1408 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1409 "./paired-devices"1410 );14111412 await upsertPairedDevice({1413 deviceId: "phone-1",1414 deviceName: "June's Phone",1415 deviceType: "android",1416 longTermPublicKeyHex: "a".repeat(64),1417 signingPublicKeyHex: "b".repeat(64),1418 pairedAt: 1_700_000_000_000,1419 trustSource: "direct",1420 });14211422 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1423 await revokeDeviceById("phone-1", "self");1424 1425 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1426 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1427 });14281429 14301431 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1432 const vault = await import("./vault");1433 await vault.createVault("correct-pass-12345");14341435 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1436 expect(res).toMatchObject({ ok: false, error: "password_required" });1437 });14381439 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1440 const vault = await import("./vault");1441 await vault.createVault("correct-pass-12345");14421443 const res = await dispatch({1444 type: "GET_RECOVERY_WORDS",1445 payload: { password: "not-the-password" },1446 });1447 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1448 expect(res.data).toBeUndefined();1449 });14501451 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1452 const vault = await import("./vault");1453 await vault.createVault("correct-pass-12345");14541455 const res = await dispatch({1456 type: "GET_RECOVERY_WORDS",1457 payload: { password: "correct-pass-12345" },1458 });1459 expect(res.ok).toBe(true);1460 expect((res.data as string[]).length).toBe(12);1461 });14621463 it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1464 const vault = await import("./vault");1465 const password = "correct-pass-12345";1466 const popupSender = {1467 id: "peach-extension",1468 url: "chrome-extension://peach-extension/src/popup/index.html",1469 } as chrome.runtime.MessageSender;14701471 await vault.createVault(password);1472 await vault.setupPinUnlock(password, "1234");1473 await vault.lock();14741475 await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1476 ok: true,1477 data: { configured: true, available: true, attemptsRemaining: 3 },1478 });1479 });1480});