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, tabId = 1, frameId = 0): chrome.runtime.MessageSender {962 return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };963 }964965 async function addPasskeyOnlyEntry(title: string, url: string) {966 const vault = await import("./vault");967 const generated = await generatePasskeyKeypair(-7);968 return vault.addEntry({969 type: "login",970 title,971 url,972 passkey: {973 rpId: new URL(url).hostname,974 rpName: title,975 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),976 publicKeyCose: base64urlEncode(generated.publicKeyCose),977 privateKeyJwk: generated.privateKeyJwk,978 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),979 userName: "june",980 userDisplayName: "June",981 algorithm: -7,982 signCount: 0,983 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",984 backupEligible: true,985 backupState: false,986 createdAt: Date.now(),987 },988 });989 }990991 it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {992 const vault = await import("./vault");993 await vault.createVault("correct-pass-12345");994 await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });995 await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });996997 const result = await dispatch(998 {999 type: "AUTOFILL_QUERY",1000 payload: {1001 domain: "github.com",1002 includeAll: true,1003 userGesture: true,1004 explicitSummon: true,1005 },1006 },1007 tabSender("https://evil.example.com/phish"),1008 );10091010 expect(result).toMatchObject({ ok: true });1011 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1012 });10131014 it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1015 const vault = await import("./vault");1016 await vault.createVault("correct-pass-12345");1017 await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1018 await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });10191020 const result = await dispatch(1021 { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1022 tabSender("https://evil.example.com/verify"),1023 );10241025 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1026 });10271028 it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1029 const vault = await import("./vault");1030 const messages = await import("./messages");1031 await vault.createVault("correct-pass-12345");1032 const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1033 const sender = tabSender("https://evil.example.com/phish");1034 const token = messages.createSummonCapability(1, "https://evil.example.com");10351036 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1037 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });10381039 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1040 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1041 const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1042 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1043 });10441045 it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1046 const vault = await import("./vault");1047 const messages = await import("./messages");1048 await vault.createVault("correct-pass-12345");1049 const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1050 const sender = tabSender("https://evil.example.com/login");1051 const token = messages.createSummonCapability(1, "https://evil.example.com");10521053 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1054 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1055 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1056 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });10571058 const offDomainReplay = await dispatch(1059 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1060 tabSender("https://github.com/login"),1061 );1062 expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1063 });10641065 it("PB-26-201: summon capability cannot fill a different entry", async () => {1066 const vault = await import("./vault");1067 const messages = await import("./messages");1068 await vault.createVault("correct-pass-12345");1069 const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1070 const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1071 const sender = tabSender("https://evil.example.com/phish");1072 const token = messages.createSummonCapability(1, "https://evil.example.com");1073 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1074 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1075 expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1076 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1077 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1078 expect(first.id).not.toBe(second.id);1079 });10801081 it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1082 const vault = await import("./vault");1083 const messages = await import("./messages");1084 await vault.createVault("correct-pass-12345");1085 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1086 const token = messages.createSummonCapability(1, "https://evil.example.com");1087 const result = await dispatch(1088 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1089 tabSender("https://evil.example.com/phish"),1090 );1091 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1092 });10931094 it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1095 const vault = await import("./vault");1096 const messages = await import("./messages");1097 await vault.createVault("correct-pass-12345");1098 const entry = await vault.addEntry({1099 title: "Evil",1100 password: "known",1101 url: "https://evil.example.com",1102 });1103 const sender = tabSender("https://evil.example.com/login");1104 const token = messages.createSummonCapability(1, "https://evil.example.com");11051106 const result = await dispatch(1107 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1108 sender,1109 );11101111 expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1112 });11131114 it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1115 vi.useFakeTimers();1116 vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1117 const vault = await import("./vault");1118 const messages = await import("./messages");1119 await vault.createVault("correct-pass-12345");1120 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1121 const sender = tabSender("https://evil.example.com/phish");1122 const token = messages.createSummonCapability(1, "https://evil.example.com");1123 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1124 vi.advanceTimersByTime(30_001);11251126 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1127 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1128 });11291130 it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1131 const vault = await import("./vault");1132 const messages = await import("./messages");1133 await vault.createVault("correct-pass-12345");1134 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1135 const token = messages.createSummonCapability(1, "https://evil.example.com");1136 await dispatch(1137 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1138 tabSender("https://evil.example.com/phish"),1139 );11401141 const result = await dispatch(1142 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1143 tabSender("https://evil.example.com/phish", 2),1144 );1145 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1146 });11471148 it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1149 const vault = await import("./vault");1150 const messages = await import("./messages");1151 await vault.createVault("correct-pass-12345");1152 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1153 const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1154 await dispatch(1155 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1156 tabSender("https://evil.example.com/phish", 1, 3),1157 );11581159 const result = await dispatch(1160 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1161 tabSender("https://evil.example.com/phish", 1, 4),1162 );1163 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1164 });11651166 it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1167 const vault = await import("./vault");1168 const messages = await import("./messages");1169 await vault.createVault("correct-pass-12345");1170 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1171 const token = messages.createSummonCapability(1, "https://evil.example.com");1172 await dispatch(1173 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1174 tabSender("https://evil.example.com/phish"),1175 );11761177 const result = await dispatch(1178 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1179 tabSender("https://other.example.com/phish"),1180 );1181 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1182 });11831184 it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1185 const vault = await import("./vault");1186 const messages = await import("./messages");1187 await vault.createVault("correct-pass-12345");1188 const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1189 const sender = tabSender("https://evil.example.com/phish");1190 const token = messages.createSummonCapability(1, "https://evil.example.com");1191 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);11921193 const missing = await dispatch(1194 { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1195 sender,1196 );1197 const existingReplay = await dispatch(1198 { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1199 sender,1200 );1201 expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1202 expect(existingReplay).toEqual(missing);1203 });12041205 it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1206 const vault = await import("./vault");1207 const messages = await import("./messages");1208 await vault.createVault("correct-pass-12345");1209 await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1210 const sender = tabSender("https://evil.example.com/phish");1211 const token = messages.createSummonCapability(1, "https://evil.example.com");1212 const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1213 const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12141215 expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1216 expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1217 });12181219 it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1220 const vault = await import("./vault");1221 const messages = await import("./messages");1222 await vault.createVault("correct-pass-12345");1223 const card = await vault.addEntry({1224 type: "card",1225 title: "Personal Visa",1226 cardholderName: "June Peach",1227 cardNumber: "4111111111111111",1228 cvc: "123",1229 expMonth: "12",1230 expYear: "2030",1231 });1232 const sender = tabSender("https://shop.example.com/checkout");1233 const passive = await dispatch(1234 { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1235 sender,1236 );1237 expect(passive).toMatchObject({ ok: true, data: [] });12381239 const token = messages.createSummonCapability(1, "https://shop.example.com");1240 const inventory = await dispatch(1241 { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1242 sender,1243 );1244 expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1245 expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");12461247 const fill = await dispatch(1248 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1249 sender,1250 );1251 expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1252 const replay = await dispatch(1253 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1254 sender,1255 );1256 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1257 });12581259 it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1260 const result = await dispatch(1261 { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1262 tabSender("https://example.com/login"),1263 );12641265 expect(result).toMatchObject({ ok: true });1266 expect(sessionStorage.peach_open_entry).toBe("entry-123");1267 expect(sessionStorage.peach_inline_unlock).toBeUndefined();1268 });12691270 it("MV-1: tab request with a matching domain returns the entry", async () => {1271 const vault = await import("./vault");1272 await vault.createVault("correct-pass-12345");1273 const entry = await vault.addEntry({1274 title: "GitHub",1275 username: "june",1276 password: "secret123",1277 url: "https://github.com",1278 });12791280 const res = await dispatch(1281 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1282 tabSender("https://github.com/login"),1283 );1284 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1285 });12861287 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1288 const vault = await import("./vault");1289 await vault.createVault("correct-pass-12345");1290 const entry = await vault.addEntry({1291 title: "GitHub",1292 username: "june",1293 password: "secret123",1294 url: "https://github.com",1295 });12961297 const res = await dispatch(1298 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1299 tabSender("https://evil.example.com/phish"),1300 );1301 1302 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1303 });13041305 it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1306 const vault = await import("./vault");1307 await vault.createVault("correct-pass-12345");1308 const entry = await vault.addEntry({1309 title: "GitHub",1310 username: "june",1311 password: "secret123",1312 url: "https://github.com",1313 });13141315 const res = await dispatch(1316 {1317 type: "GET_ENTRY_FOR_FILL",1318 payload: { id: entry.id, explicitSummon: true },1319 },1320 tabSender("https://evil.example.com/phish"),1321 );1322 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1323 });13241325 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1326 const vault = await import("./vault");1327 await vault.createVault("correct-pass-12345");1328 const entry = await vault.addEntry({1329 title: "GitHub",1330 username: "june",1331 password: "secret123",1332 url: "https://github.com",1333 });13341335 const res = await dispatch(1336 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1337 tabSender("not a url"),1338 );1339 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1340 });13411342 13431344 async function seedTwoDomainVault() {1345 const vault = await import("./vault");1346 await vault.createVault("correct-pass-12345");1347 await vault.addEntry({1348 title: "GitHub",1349 username: "june",1350 password: "gh",1351 url: "https://github.com",1352 });1353 await vault.addEntry({1354 title: "Example",1355 username: "june",1356 password: "ex",1357 url: "https://example.com",1358 });1359 }13601361 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1362 await seedTwoDomainVault();13631364 const res = await dispatch({1365 type: "AUTOFILL_QUERY",1366 payload: { domain: "github.com", includeAll: true },1367 });1368 expect(res.ok).toBe(true);1369 const data = res.data as Array<{ title: string }>;1370 1371 expect(data).toHaveLength(1);1372 expect(data[0].title).toBe("GitHub");1373 });13741375 it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1376 await seedTwoDomainVault();13771378 const res = await dispatch({1379 type: "AUTOFILL_QUERY",1380 payload: { domain: "github.com", includeAll: true, userGesture: true },1381 });1382 expect(res.ok).toBe(true);1383 const data = res.data as Array<{ title: string }>;1384 expect(data.map((d) => d.title)).toEqual(["GitHub"]);1385 });13861387 13881389 async function seedPendingVouched(deviceId: string) {1390 await chrome.storage.local.set({1391 peach_pending_vouched_devices_v1: [{1392 deviceId,1393 deviceName: "New Pixel",1394 deviceType: "android",1395 longTermPublicKeyHex: "e".repeat(64),1396 signingPublicKeyHex: "a".repeat(64),1397 wsPort: 51234,1398 pairedAt: 1_700_000_000_020,1399 receivedAt: 1_700_000_000_050,1400 vouchedByDeviceId: "extension-issuer",1401 }],1402 });1403 }14041405 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1406 const vault = await import("./vault");1407 await vault.createVault("correct-pass-12345");1408 await seedPendingVouched("android-new");1409 const { findPairedDeviceById } = await import("./paired-devices");1410 const { listPendingVouchedDevices } = await import(1411 "./pending-vouched-devices"1412 );14131414 const res = await dispatch({1415 type: "CONFIRM_VOUCHED_DEVICE",1416 payload: { deviceId: "android-new" },1417 });1418 expect(res).toMatchObject({ ok: true });14191420 expect(await findPairedDeviceById("android-new")).toMatchObject({1421 deviceName: "New Pixel",1422 trustSource: "vouched",1423 longTermPublicKeyHex: "e".repeat(64),1424 });1425 expect(await listPendingVouchedDevices()).toHaveLength(0);1426 });14271428 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1429 const vault = await import("./vault");1430 await vault.createVault("correct-pass-12345");1431 await seedPendingVouched("android-relabelled");1432 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1433 await import("./paired-devices");1434 const { listPendingVouchedDevices } = await import(1435 "./pending-vouched-devices"1436 );14371438 1439 1440 await upsertPairedDevice({1441 deviceId: "android-old",1442 deviceName: "New Pixel",1443 deviceType: "android",1444 longTermPublicKeyHex: "e".repeat(64),1445 signingPublicKeyHex: "a".repeat(64),1446 pairedAt: 1_700_000_000_000,1447 trustSource: "vouched",1448 });1449 await revokeDeviceById("android-old", "self");14501451 const res = await dispatch({1452 type: "CONFIRM_VOUCHED_DEVICE",1453 payload: { deviceId: "android-relabelled" },1454 });1455 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1456 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1457 expect(await listPendingVouchedDevices()).toHaveLength(0);1458 });14591460 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1461 const vault = await import("./vault");1462 await vault.createVault("correct-pass-12345");1463 await seedPendingVouched("android-new");1464 const { findPairedDeviceById } = await import("./paired-devices");1465 const { listPendingVouchedDevices } = await import(1466 "./pending-vouched-devices"1467 );14681469 const res = await dispatch({1470 type: "REJECT_VOUCHED_DEVICE",1471 payload: { deviceId: "android-new" },1472 });1473 expect(res).toMatchObject({ ok: true });14741475 expect(await findPairedDeviceById("android-new")).toBeNull();1476 expect(await listPendingVouchedDevices()).toHaveLength(0);1477 });14781479 14801481 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1482 const vault = await import("./vault");1483 await vault.createVault("correct-pass-12345");1484 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1485 await import("./paired-devices");14861487 await upsertPairedDevice({1488 deviceId: "phone-1",1489 deviceName: "June's Phone",1490 deviceType: "android",1491 longTermPublicKeyHex: "a".repeat(64),1492 signingPublicKeyHex: "b".repeat(64),1493 pairedAt: 1_700_000_000_000,1494 trustSource: "direct",1495 });14961497 const res = await dispatch({1498 type: "UNPAIR_DEVICE",1499 payload: { deviceId: "phone-1" },1500 });1501 expect(res).toMatchObject({ ok: true });15021503 1504 expect(await findPairedDeviceById("phone-1")).toBeNull();1505 1506 1507 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1508 const records = await listDeviceRevocations();1509 expect(records).toHaveLength(1);1510 expect(records[0]).toMatchObject({1511 deviceId: "phone-1",1512 longTermPublicKeyHex: "a".repeat(64),1513 revokedByDeviceId: expect.any(String),1514 });1515 });15161517 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1518 const vault = await import("./vault");1519 await vault.createVault("correct-pass-12345");1520 const { listDeviceRevocations } = await import("./paired-devices");15211522 const res = await dispatch({1523 type: "UNPAIR_DEVICE",1524 payload: { deviceId: "never-paired" },1525 });1526 expect(res).toMatchObject({ ok: true });1527 1528 1529 1530 1531 const records = await listDeviceRevocations();1532 expect(records).toHaveLength(1);1533 expect(records[0]).toMatchObject({1534 deviceId: "never-paired",1535 longTermPublicKeyHex: "0".repeat(64),1536 });1537 });15381539 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1540 const res = await dispatch({1541 type: "UNPAIR_DEVICE",1542 payload: { deviceId: "" },1543 });1544 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1545 });15461547 1548 1549 1550 1551 1552 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1553 const vault = await import("./vault");1554 await vault.createVault("correct-pass-12345");1555 const contentScriptSender = {1556 id: "peach-extension",1557 url: "https://evil.example/page",1558 };1559 const extensionPageSender = {1560 id: "peach-extension",1561 url: "chrome-extension://peach-extension/popup.html",1562 };1563 const completion = { requestId: 1, rpId: "evil.example" };15641565 1566 await expect(1567 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1568 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });15691570 1571 1572 await expect(1573 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1574 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1575 });15761577 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1578 const vault = await import("./vault");1579 await vault.createVault("correct-pass-12345");1580 const extensionPageSender = {1581 id: "peach-extension",1582 url: "chrome-extension://peach-extension/popup.html",1583 };1584 const completion = {1585 requestId: 123,1586 rpId: "example.com",1587 rpName: "Example",1588 userName: "june",1589 userHandle: "BBBB",1590 credentialId: "cred-1",1591 publicKeyCose: "c3BraQ",1592 algorithm: -7 as const,1593 };15941595 1596 await expect(1597 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1598 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });15991600 1601 sessionStorage.passkey_pending_firefox = {1602 requestId: 123,1603 type: "create",1604 rpId: "example.com",1605 rpName: "Example",1606 userName: "june",1607 browser: "firefox",1608 challenge: "AAAA",1609 userHandle: "BBBB",1610 };16111612 1613 await expect(1614 dispatch(1615 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1616 extensionPageSender1617 )1618 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });16191620 1621 await expect(1622 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1623 ).resolves.toMatchObject({ ok: true });1624 const entries = await dispatch({ type: "GET_ENTRIES" });1625 expect(entries).toMatchObject({ ok: true });1626 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1627 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1628 });16291630 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1631 await expect(1632 dispatch(1633 {1634 type: "INITIATE_FIREFOX_PASSKEY_CREATE",1635 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1636 },1637 { id: "peach-extension", url: "https://evil.example/page" }1638 )1639 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1640 });16411642 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1643 1644 1645 1646 1647 const vault = await import("./vault");1648 await vault.createVault("correct-pass-12345");1649 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1650 "./paired-devices"1651 );16521653 await upsertPairedDevice({1654 deviceId: "phone-1",1655 deviceName: "June's Phone",1656 deviceType: "android",1657 longTermPublicKeyHex: "a".repeat(64),1658 signingPublicKeyHex: "b".repeat(64),1659 pairedAt: 1_700_000_000_000,1660 trustSource: "direct",1661 });16621663 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1664 await revokeDeviceById("phone-1", "self");1665 1666 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1667 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1668 });16691670 16711672 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1673 const vault = await import("./vault");1674 await vault.createVault("correct-pass-12345");16751676 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1677 expect(res).toMatchObject({ ok: false, error: "password_required" });1678 });16791680 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1681 const vault = await import("./vault");1682 await vault.createVault("correct-pass-12345");16831684 const res = await dispatch({1685 type: "GET_RECOVERY_WORDS",1686 payload: { password: "not-the-password" },1687 });1688 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1689 expect(res.data).toBeUndefined();1690 });16911692 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1693 const vault = await import("./vault");1694 await vault.createVault("correct-pass-12345");16951696 const res = await dispatch({1697 type: "GET_RECOVERY_WORDS",1698 payload: { password: "correct-pass-12345" },1699 });1700 expect(res.ok).toBe(true);1701 expect((res.data as string[]).length).toBe(12);1702 });17031704 it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1705 const vault = await import("./vault");1706 const password = "correct-pass-12345";1707 const popupSender = {1708 id: "peach-extension",1709 url: "chrome-extension://peach-extension/src/popup/index.html",1710 } as chrome.runtime.MessageSender;17111712 await vault.createVault(password);1713 await vault.setupPinUnlock(password, "1234");1714 await vault.lock();17151716 await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1717 ok: true,1718 data: { configured: true, available: true, attemptsRemaining: 3 },1719 });1720 });1721});