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: { url } as chrome.tabs.Tab };963 }964965 it("MV-1: tab request with a matching domain returns the entry", async () => {966 const vault = await import("./vault");967 await vault.createVault("correct-pass-12345");968 const entry = await vault.addEntry({969 title: "GitHub",970 username: "june",971 password: "secret123",972 url: "https://github.com",973 });974975 const res = await dispatch(976 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },977 tabSender("https://github.com/login"),978 );979 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });980 });981982 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {983 const vault = await import("./vault");984 await vault.createVault("correct-pass-12345");985 const entry = await vault.addEntry({986 title: "GitHub",987 username: "june",988 password: "secret123",989 url: "https://github.com",990 });991992 const res = await dispatch(993 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },994 tabSender("https://evil.example.com/phish"),995 );996 997 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });998 });9991000 it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1001 const vault = await import("./vault");1002 await vault.createVault("correct-pass-12345");1003 const entry = await vault.addEntry({1004 title: "GitHub",1005 username: "june",1006 password: "secret123",1007 url: "https://github.com",1008 });10091010 const res = await dispatch(1011 {1012 type: "GET_ENTRY_FOR_FILL",1013 payload: { id: entry.id, explicitSummon: true },1014 },1015 tabSender("https://evil.example.com/phish"),1016 );1017 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1018 });10191020 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1021 const vault = await import("./vault");1022 await vault.createVault("correct-pass-12345");1023 const entry = await vault.addEntry({1024 title: "GitHub",1025 username: "june",1026 password: "secret123",1027 url: "https://github.com",1028 });10291030 const res = await dispatch(1031 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1032 tabSender("not a url"),1033 );1034 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1035 });10361037 10381039 async function seedTwoDomainVault() {1040 const vault = await import("./vault");1041 await vault.createVault("correct-pass-12345");1042 await vault.addEntry({1043 title: "GitHub",1044 username: "june",1045 password: "gh",1046 url: "https://github.com",1047 });1048 await vault.addEntry({1049 title: "Example",1050 username: "june",1051 password: "ex",1052 url: "https://example.com",1053 });1054 }10551056 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1057 await seedTwoDomainVault();10581059 const res = await dispatch({1060 type: "AUTOFILL_QUERY",1061 payload: { domain: "github.com", includeAll: true },1062 });1063 expect(res.ok).toBe(true);1064 const data = res.data as Array<{ title: string }>;1065 1066 expect(data).toHaveLength(1);1067 expect(data[0].title).toBe("GitHub");1068 });10691070 it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1071 await seedTwoDomainVault();10721073 const res = await dispatch({1074 type: "AUTOFILL_QUERY",1075 payload: { domain: "github.com", includeAll: true, userGesture: true },1076 });1077 expect(res.ok).toBe(true);1078 const data = res.data as Array<{ title: string }>;1079 expect(data.map((d) => d.title)).toEqual(["GitHub"]);1080 });10811082 10831084 async function seedPendingVouched(deviceId: string) {1085 await chrome.storage.local.set({1086 peach_pending_vouched_devices_v1: [{1087 deviceId,1088 deviceName: "New Pixel",1089 deviceType: "android",1090 longTermPublicKeyHex: "e".repeat(64),1091 signingPublicKeyHex: "a".repeat(64),1092 wsPort: 51234,1093 pairedAt: 1_700_000_000_020,1094 receivedAt: 1_700_000_000_050,1095 vouchedByDeviceId: "extension-issuer",1096 }],1097 });1098 }10991100 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1101 const vault = await import("./vault");1102 await vault.createVault("correct-pass-12345");1103 await seedPendingVouched("android-new");1104 const { findPairedDeviceById } = await import("./paired-devices");1105 const { listPendingVouchedDevices } = await import(1106 "./pending-vouched-devices"1107 );11081109 const res = await dispatch({1110 type: "CONFIRM_VOUCHED_DEVICE",1111 payload: { deviceId: "android-new" },1112 });1113 expect(res).toMatchObject({ ok: true });11141115 expect(await findPairedDeviceById("android-new")).toMatchObject({1116 deviceName: "New Pixel",1117 trustSource: "vouched",1118 longTermPublicKeyHex: "e".repeat(64),1119 });1120 expect(await listPendingVouchedDevices()).toHaveLength(0);1121 });11221123 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1124 const vault = await import("./vault");1125 await vault.createVault("correct-pass-12345");1126 await seedPendingVouched("android-relabelled");1127 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1128 await import("./paired-devices");1129 const { listPendingVouchedDevices } = await import(1130 "./pending-vouched-devices"1131 );11321133 1134 1135 await upsertPairedDevice({1136 deviceId: "android-old",1137 deviceName: "New Pixel",1138 deviceType: "android",1139 longTermPublicKeyHex: "e".repeat(64),1140 signingPublicKeyHex: "a".repeat(64),1141 pairedAt: 1_700_000_000_000,1142 trustSource: "vouched",1143 });1144 await revokeDeviceById("android-old", "self");11451146 const res = await dispatch({1147 type: "CONFIRM_VOUCHED_DEVICE",1148 payload: { deviceId: "android-relabelled" },1149 });1150 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1151 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1152 expect(await listPendingVouchedDevices()).toHaveLength(0);1153 });11541155 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1156 const vault = await import("./vault");1157 await vault.createVault("correct-pass-12345");1158 await seedPendingVouched("android-new");1159 const { findPairedDeviceById } = await import("./paired-devices");1160 const { listPendingVouchedDevices } = await import(1161 "./pending-vouched-devices"1162 );11631164 const res = await dispatch({1165 type: "REJECT_VOUCHED_DEVICE",1166 payload: { deviceId: "android-new" },1167 });1168 expect(res).toMatchObject({ ok: true });11691170 expect(await findPairedDeviceById("android-new")).toBeNull();1171 expect(await listPendingVouchedDevices()).toHaveLength(0);1172 });11731174 11751176 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1177 const vault = await import("./vault");1178 await vault.createVault("correct-pass-12345");1179 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1180 await import("./paired-devices");11811182 await upsertPairedDevice({1183 deviceId: "phone-1",1184 deviceName: "June's Phone",1185 deviceType: "android",1186 longTermPublicKeyHex: "a".repeat(64),1187 signingPublicKeyHex: "b".repeat(64),1188 pairedAt: 1_700_000_000_000,1189 trustSource: "direct",1190 });11911192 const res = await dispatch({1193 type: "UNPAIR_DEVICE",1194 payload: { deviceId: "phone-1" },1195 });1196 expect(res).toMatchObject({ ok: true });11971198 1199 expect(await findPairedDeviceById("phone-1")).toBeNull();1200 1201 1202 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1203 const records = await listDeviceRevocations();1204 expect(records).toHaveLength(1);1205 expect(records[0]).toMatchObject({1206 deviceId: "phone-1",1207 longTermPublicKeyHex: "a".repeat(64),1208 revokedByDeviceId: expect.any(String),1209 });1210 });12111212 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1213 const vault = await import("./vault");1214 await vault.createVault("correct-pass-12345");1215 const { listDeviceRevocations } = await import("./paired-devices");12161217 const res = await dispatch({1218 type: "UNPAIR_DEVICE",1219 payload: { deviceId: "never-paired" },1220 });1221 expect(res).toMatchObject({ ok: true });1222 1223 1224 1225 1226 const records = await listDeviceRevocations();1227 expect(records).toHaveLength(1);1228 expect(records[0]).toMatchObject({1229 deviceId: "never-paired",1230 longTermPublicKeyHex: "0".repeat(64),1231 });1232 });12331234 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1235 const res = await dispatch({1236 type: "UNPAIR_DEVICE",1237 payload: { deviceId: "" },1238 });1239 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1240 });12411242 1243 1244 1245 1246 1247 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1248 const vault = await import("./vault");1249 await vault.createVault("correct-pass-12345");1250 const contentScriptSender = {1251 id: "peach-extension",1252 url: "https://evil.example/page",1253 };1254 const extensionPageSender = {1255 id: "peach-extension",1256 url: "chrome-extension://peach-extension/popup.html",1257 };1258 const completion = { requestId: 1, rpId: "evil.example" };12591260 1261 await expect(1262 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1263 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });12641265 1266 1267 await expect(1268 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1269 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1270 });12711272 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1273 const vault = await import("./vault");1274 await vault.createVault("correct-pass-12345");1275 const extensionPageSender = {1276 id: "peach-extension",1277 url: "chrome-extension://peach-extension/popup.html",1278 };1279 const completion = {1280 requestId: 123,1281 rpId: "example.com",1282 rpName: "Example",1283 userName: "june",1284 userHandle: "BBBB",1285 credentialId: "cred-1",1286 publicKeyCose: "c3BraQ",1287 algorithm: -7 as const,1288 };12891290 1291 await expect(1292 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1293 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });12941295 1296 sessionStorage.passkey_pending_firefox = {1297 requestId: 123,1298 type: "create",1299 rpId: "example.com",1300 rpName: "Example",1301 userName: "june",1302 browser: "firefox",1303 challenge: "AAAA",1304 userHandle: "BBBB",1305 };13061307 1308 await expect(1309 dispatch(1310 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1311 extensionPageSender1312 )1313 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });13141315 1316 await expect(1317 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1318 ).resolves.toMatchObject({ ok: true });1319 const entries = await dispatch({ type: "GET_ENTRIES" });1320 expect(entries).toMatchObject({ ok: true });1321 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1322 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1323 });13241325 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1326 await expect(1327 dispatch(1328 {1329 type: "INITIATE_FIREFOX_PASSKEY_CREATE",1330 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1331 },1332 { id: "peach-extension", url: "https://evil.example/page" }1333 )1334 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1335 });13361337 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1338 1339 1340 1341 1342 const vault = await import("./vault");1343 await vault.createVault("correct-pass-12345");1344 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1345 "./paired-devices"1346 );13471348 await upsertPairedDevice({1349 deviceId: "phone-1",1350 deviceName: "June's Phone",1351 deviceType: "android",1352 longTermPublicKeyHex: "a".repeat(64),1353 signingPublicKeyHex: "b".repeat(64),1354 pairedAt: 1_700_000_000_000,1355 trustSource: "direct",1356 });13571358 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1359 await revokeDeviceById("phone-1", "self");1360 1361 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1362 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1363 });13641365 13661367 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1368 const vault = await import("./vault");1369 await vault.createVault("correct-pass-12345");13701371 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1372 expect(res).toMatchObject({ ok: false, error: "password_required" });1373 });13741375 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1376 const vault = await import("./vault");1377 await vault.createVault("correct-pass-12345");13781379 const res = await dispatch({1380 type: "GET_RECOVERY_WORDS",1381 payload: { password: "not-the-password" },1382 });1383 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1384 expect(res.data).toBeUndefined();1385 });13861387 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1388 const vault = await import("./vault");1389 await vault.createVault("correct-pass-12345");13901391 const res = await dispatch({1392 type: "GET_RECOVERY_WORDS",1393 payload: { password: "correct-pass-12345" },1394 });1395 expect(res.ok).toBe(true);1396 expect((res.data as string[]).length).toBe(12);1397 });13981399 it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1400 const vault = await import("./vault");1401 const password = "correct-pass-12345";1402 const popupSender = {1403 id: "peach-extension",1404 url: "chrome-extension://peach-extension/src/popup/index.html",1405 } as chrome.runtime.MessageSender;14061407 await vault.createVault(password);1408 await vault.setupPinUnlock(password, "1234");1409 await vault.lock();14101411 await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1412 ok: true,1413 data: { configured: true, available: true, attemptsRemaining: 3 },1414 });1415 });1416});