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("GET_IDENTITIES rejects content-script and page-relay senders", async () => {818 const vault = await import("./vault");819 await vault.createVault("correct-pass-12345");820 await vault.addEntry({821 title: "Private identity",822 username: "[email protected]",823 password: "pw",824 url: "https://private.example",825 });826827 const senders = [828 {829 id: "peach-extension",830 url: "https://private.example/signup",831 tab: { id: 1 } as chrome.tabs.Tab,832 },833 { id: "peach-extension", url: "https://private.example/signup" },834 ] satisfies chrome.runtime.MessageSender[];835 for (const sender of senders) {836 await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({837 ok: false,838 error: "invalid_sender",839 });840 }841 });842843 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {844 const vault = await import("./vault");845 await vault.createVault("correct-pass-12345");846 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;847848 849 alarmsCreate.mockClear();850 await dispatch({ type: "GET_ENTRIES" });851 expect(alarmsCreate).toHaveBeenCalled();852853 854 alarmsCreate.mockClear();855 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });856 expect(alarmsCreate).not.toHaveBeenCalled();857 });858859 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {860 const vault = await import("./vault");861 await vault.createVault("correct-pass-12345");862 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });863864 865 storage.peach_license = "PEACH-PRO-TEST-KEY";866 storage.peach_pro_celebrated = true;867868 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });869 expect(res).toMatchObject({ ok: true });870871 872 expect(storage.peach_vault_a).toBeUndefined();873 874 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");875 expect(storage.peach_pro_celebrated).toBe(true);876 });877878 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {879 const messages = await import("./messages");880 const vault = await import("./vault");881882 let resolveGate: () => void = () => {};883 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));884885 let settled = false;886 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });887888 889 890 await new Promise((r) => setTimeout(r, 20));891 expect(settled).toBe(false);892893 894 await vault.createVault("correct-pass-12345");895 resolveGate();896897 await expect(pending).resolves.toMatchObject({ ok: true, data: true });898 });899900 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {901 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });902 expect(res.ok).toBe(false);903 904 const has = await dispatch({ type: "HAS_VAULT" });905 expect((has as { data?: boolean }).data).toBe(false);906 });907908 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {909 const vault = await import("./vault");910 await vault.createVault("correct-pass-12345");911 const e = await vault.addEntry({912 title: "Mail",913 username: "june",914 password: "pw",915 url: "https://mail.test",916 totp: "JBSWY3DPEHPK3PXP",917 });918919 const res = await dispatch(920 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },921 { id: "peach-extension", url: "https://mail.test/login" },922 );923 expect(res.ok).toBe(true);924 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };925 expect(data.totp).toBeUndefined();926 expect(data.totpCode).toMatch(/^\d{6}$/);927 expect(data.totpRemaining).toBeGreaterThan(0);928 expect(data.totpRemaining).toBeLessThanOrEqual(30);929 });930931 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {932 const vault = await import("./vault");933 await vault.createVault("correct-pass-12345");934 const e = await vault.addEntry({935 title: "Bank",936 username: "june",937 password: "old-pw",938 url: "https://bank.test",939 });940 await vault.updateEntry(e.id, { password: "new-pw" });941942 const fill = await dispatch(943 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },944 { id: "peach-extension", url: "https://bank.test/login" },945 );946 expect(fill.ok).toBe(true);947 948 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();949950 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });951 expect(edit.ok).toBe(true);952 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];953 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);954 });955956 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {957 const vault = await import("./vault");958 await vault.createVault("correct-pass-12345");959 const e = await vault.addEntry({960 title: "Mail",961 username: "june",962 password: "pw",963 url: "https://mail.test",964 totp: "JBSWY3DPEHPK3PXP",965 });966967 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });968 expect(res.ok).toBe(true);969 const data = res.data as { totp?: string };970 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");971 });972973 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {974 const vault = await import("./vault");975 await vault.createVault("correct-pass-12345");976977 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });978 expect(res).toMatchObject({ ok: true });979980 expect(storage.peach_vault_a).toBeUndefined();981 expect(storage.peach_license).toBeUndefined();982 expect(storage.peach_pro_celebrated).toBeUndefined();983 });984985 986987 function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {988 return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };989 }990991 async function addPasskeyOnlyEntry(title: string, url: string) {992 const vault = await import("./vault");993 const generated = await generatePasskeyKeypair(-7);994 return vault.addEntry({995 type: "login",996 title,997 url,998 passkey: {999 rpId: new URL(url).hostname,1000 rpName: title,1001 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1002 publicKeyCose: base64urlEncode(generated.publicKeyCose),1003 privateKeyJwk: generated.privateKeyJwk,1004 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1005 userName: "june",1006 userDisplayName: "June",1007 algorithm: -7,1008 signCount: 0,1009 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1010 backupEligible: true,1011 backupState: false,1012 createdAt: Date.now(),1013 },1014 });1015 }10161017 it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1018 const vault = await import("./vault");1019 await vault.createVault("correct-pass-12345");1020 await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1021 await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });10221023 const result = await dispatch(1024 {1025 type: "AUTOFILL_QUERY",1026 payload: {1027 domain: "github.com",1028 includeAll: true,1029 userGesture: true,1030 explicitSummon: true,1031 },1032 },1033 tabSender("https://evil.example.com/phish"),1034 );10351036 expect(result).toMatchObject({ ok: true });1037 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1038 });10391040 it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1041 const vault = await import("./vault");1042 await vault.createVault("correct-pass-12345");1043 await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1044 await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });10451046 const result = await dispatch(1047 { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1048 tabSender("https://evil.example.com/verify"),1049 );10501051 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1052 });10531054 it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1055 const vault = await import("./vault");1056 const messages = await import("./messages");1057 await vault.createVault("correct-pass-12345");1058 const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1059 const sender = tabSender("https://evil.example.com/phish");1060 const token = messages.createSummonCapability(1, "https://evil.example.com");10611062 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1063 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });10641065 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1066 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1067 const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1068 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1069 });10701071 it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1072 const vault = await import("./vault");1073 const messages = await import("./messages");1074 await vault.createVault("correct-pass-12345");1075 const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1076 const sender = tabSender("https://evil.example.com/login");1077 const token = messages.createSummonCapability(1, "https://evil.example.com");10781079 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1080 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1081 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1082 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });10831084 const offDomainReplay = await dispatch(1085 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1086 tabSender("https://github.com/login"),1087 );1088 expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1089 });10901091 it("PB-26-201: summon capability cannot fill a different entry", async () => {1092 const vault = await import("./vault");1093 const messages = await import("./messages");1094 await vault.createVault("correct-pass-12345");1095 const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1096 const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1097 const sender = tabSender("https://evil.example.com/phish");1098 const token = messages.createSummonCapability(1, "https://evil.example.com");1099 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1100 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1101 expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1102 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1103 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1104 expect(first.id).not.toBe(second.id);1105 });11061107 it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1108 const vault = await import("./vault");1109 const messages = await import("./messages");1110 await vault.createVault("correct-pass-12345");1111 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1112 const token = messages.createSummonCapability(1, "https://evil.example.com");1113 const result = await dispatch(1114 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1115 tabSender("https://evil.example.com/phish"),1116 );1117 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1118 });11191120 it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1121 const vault = await import("./vault");1122 const messages = await import("./messages");1123 await vault.createVault("correct-pass-12345");1124 const entry = await vault.addEntry({1125 title: "Evil",1126 password: "known",1127 url: "https://evil.example.com",1128 });1129 const sender = tabSender("https://evil.example.com/login");1130 const token = messages.createSummonCapability(1, "https://evil.example.com");11311132 const result = await dispatch(1133 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1134 sender,1135 );11361137 expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1138 });11391140 it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1141 vi.useFakeTimers();1142 vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1143 const vault = await import("./vault");1144 const messages = await import("./messages");1145 await vault.createVault("correct-pass-12345");1146 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1147 const sender = tabSender("https://evil.example.com/phish");1148 const token = messages.createSummonCapability(1, "https://evil.example.com");1149 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1150 vi.advanceTimersByTime(30_001);11511152 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1153 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1154 });11551156 it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1157 const vault = await import("./vault");1158 const messages = await import("./messages");1159 await vault.createVault("correct-pass-12345");1160 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1161 const token = messages.createSummonCapability(1, "https://evil.example.com");1162 await dispatch(1163 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1164 tabSender("https://evil.example.com/phish"),1165 );11661167 const result = await dispatch(1168 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1169 tabSender("https://evil.example.com/phish", 2),1170 );1171 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1172 });11731174 it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1175 const vault = await import("./vault");1176 const messages = await import("./messages");1177 await vault.createVault("correct-pass-12345");1178 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1179 const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1180 await dispatch(1181 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1182 tabSender("https://evil.example.com/phish", 1, 3),1183 );11841185 const result = await dispatch(1186 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1187 tabSender("https://evil.example.com/phish", 1, 4),1188 );1189 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1190 });11911192 it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1193 const vault = await import("./vault");1194 const messages = await import("./messages");1195 await vault.createVault("correct-pass-12345");1196 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1197 const token = messages.createSummonCapability(1, "https://evil.example.com");1198 await dispatch(1199 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1200 tabSender("https://evil.example.com/phish"),1201 );12021203 const result = await dispatch(1204 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1205 tabSender("https://other.example.com/phish"),1206 );1207 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1208 });12091210 it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1211 const vault = await import("./vault");1212 const messages = await import("./messages");1213 await vault.createVault("correct-pass-12345");1214 const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1215 const sender = tabSender("https://evil.example.com/phish");1216 const token = messages.createSummonCapability(1, "https://evil.example.com");1217 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12181219 const missing = await dispatch(1220 { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1221 sender,1222 );1223 const existingReplay = await dispatch(1224 { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1225 sender,1226 );1227 expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1228 expect(existingReplay).toEqual(missing);1229 });12301231 it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1232 const vault = await import("./vault");1233 const messages = await import("./messages");1234 await vault.createVault("correct-pass-12345");1235 await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1236 const sender = tabSender("https://evil.example.com/phish");1237 const token = messages.createSummonCapability(1, "https://evil.example.com");1238 const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1239 const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12401241 expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1242 expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1243 });12441245 it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1246 const vault = await import("./vault");1247 const messages = await import("./messages");1248 await vault.createVault("correct-pass-12345");1249 const card = await vault.addEntry({1250 type: "card",1251 title: "Personal Visa",1252 cardholderName: "June Peach",1253 cardNumber: "4111111111111111",1254 cvc: "123",1255 expMonth: "12",1256 expYear: "2030",1257 });1258 const sender = tabSender("https://shop.example.com/checkout");1259 const passive = await dispatch(1260 { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1261 sender,1262 );1263 expect(passive).toMatchObject({ ok: true, data: [] });12641265 const token = messages.createSummonCapability(1, "https://shop.example.com");1266 const inventory = await dispatch(1267 { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1268 sender,1269 );1270 expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1271 expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");12721273 const fill = await dispatch(1274 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1275 sender,1276 );1277 expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1278 const replay = await dispatch(1279 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1280 sender,1281 );1282 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1283 });12841285 it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1286 const result = await dispatch(1287 { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1288 tabSender("https://example.com/login"),1289 );12901291 expect(result).toMatchObject({ ok: true });1292 expect(sessionStorage.peach_open_entry).toBe("entry-123");1293 expect(sessionStorage.peach_inline_unlock).toBeUndefined();1294 });12951296 it("MV-1: tab request with a matching domain returns the entry", async () => {1297 const vault = await import("./vault");1298 await vault.createVault("correct-pass-12345");1299 const entry = await vault.addEntry({1300 title: "GitHub",1301 username: "june",1302 password: "secret123",1303 url: "https://github.com",1304 });13051306 const res = await dispatch(1307 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1308 tabSender("https://github.com/login"),1309 );1310 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1311 });13121313 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1314 const vault = await import("./vault");1315 await vault.createVault("correct-pass-12345");1316 const entry = await vault.addEntry({1317 title: "GitHub",1318 username: "june",1319 password: "secret123",1320 url: "https://github.com",1321 });13221323 const res = await dispatch(1324 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1325 tabSender("https://evil.example.com/phish"),1326 );1327 1328 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1329 });13301331 it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1332 const vault = await import("./vault");1333 await vault.createVault("correct-pass-12345");1334 const entry = await vault.addEntry({1335 title: "GitHub",1336 username: "june",1337 password: "secret123",1338 url: "https://github.com",1339 });13401341 const res = await dispatch(1342 {1343 type: "GET_ENTRY_FOR_FILL",1344 payload: { id: entry.id, explicitSummon: true },1345 },1346 tabSender("https://evil.example.com/phish"),1347 );1348 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1349 });13501351 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1352 const vault = await import("./vault");1353 await vault.createVault("correct-pass-12345");1354 const entry = await vault.addEntry({1355 title: "GitHub",1356 username: "june",1357 password: "secret123",1358 url: "https://github.com",1359 });13601361 const res = await dispatch(1362 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1363 tabSender("not a url"),1364 );1365 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1366 });13671368 13691370 async function seedTwoDomainVault() {1371 const vault = await import("./vault");1372 await vault.createVault("correct-pass-12345");1373 await vault.addEntry({1374 title: "GitHub",1375 username: "june",1376 password: "gh",1377 url: "https://github.com",1378 });1379 await vault.addEntry({1380 title: "Example",1381 username: "june",1382 password: "ex",1383 url: "https://example.com",1384 });1385 }13861387 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1388 await seedTwoDomainVault();13891390 const res = await dispatch({1391 type: "AUTOFILL_QUERY",1392 payload: { domain: "github.com", includeAll: true },1393 });1394 expect(res.ok).toBe(true);1395 const data = res.data as Array<{ title: string }>;1396 1397 expect(data).toHaveLength(1);1398 expect(data[0].title).toBe("GitHub");1399 });14001401 it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1402 await seedTwoDomainVault();14031404 const res = await dispatch({1405 type: "AUTOFILL_QUERY",1406 payload: { domain: "github.com", includeAll: true, userGesture: true },1407 });1408 expect(res.ok).toBe(true);1409 const data = res.data as Array<{ title: string }>;1410 expect(data.map((d) => d.title)).toEqual(["GitHub"]);1411 });14121413 14141415 async function seedPendingVouched(deviceId: string) {1416 await chrome.storage.local.set({1417 peach_pending_vouched_devices_v1: [{1418 deviceId,1419 deviceName: "New Pixel",1420 deviceType: "android",1421 longTermPublicKeyHex: "e".repeat(64),1422 signingPublicKeyHex: "a".repeat(64),1423 wsPort: 51234,1424 pairedAt: 1_700_000_000_020,1425 receivedAt: 1_700_000_000_050,1426 vouchedByDeviceId: "extension-issuer",1427 }],1428 });1429 }14301431 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1432 const vault = await import("./vault");1433 await vault.createVault("correct-pass-12345");1434 await seedPendingVouched("android-new");1435 const { findPairedDeviceById } = await import("./paired-devices");1436 const { listPendingVouchedDevices } = await import(1437 "./pending-vouched-devices"1438 );14391440 const res = await dispatch({1441 type: "CONFIRM_VOUCHED_DEVICE",1442 payload: { deviceId: "android-new" },1443 });1444 expect(res).toMatchObject({ ok: true });14451446 expect(await findPairedDeviceById("android-new")).toMatchObject({1447 deviceName: "New Pixel",1448 trustSource: "vouched",1449 longTermPublicKeyHex: "e".repeat(64),1450 });1451 expect(await listPendingVouchedDevices()).toHaveLength(0);1452 });14531454 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1455 const vault = await import("./vault");1456 await vault.createVault("correct-pass-12345");1457 await seedPendingVouched("android-relabelled");1458 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1459 await import("./paired-devices");1460 const { listPendingVouchedDevices } = await import(1461 "./pending-vouched-devices"1462 );14631464 1465 1466 await upsertPairedDevice({1467 deviceId: "android-old",1468 deviceName: "New Pixel",1469 deviceType: "android",1470 longTermPublicKeyHex: "e".repeat(64),1471 signingPublicKeyHex: "a".repeat(64),1472 pairedAt: 1_700_000_000_000,1473 trustSource: "vouched",1474 });1475 await revokeDeviceById("android-old", "self");14761477 const res = await dispatch({1478 type: "CONFIRM_VOUCHED_DEVICE",1479 payload: { deviceId: "android-relabelled" },1480 });1481 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1482 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1483 expect(await listPendingVouchedDevices()).toHaveLength(0);1484 });14851486 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1487 const vault = await import("./vault");1488 await vault.createVault("correct-pass-12345");1489 await seedPendingVouched("android-new");1490 const { findPairedDeviceById } = await import("./paired-devices");1491 const { listPendingVouchedDevices } = await import(1492 "./pending-vouched-devices"1493 );14941495 const res = await dispatch({1496 type: "REJECT_VOUCHED_DEVICE",1497 payload: { deviceId: "android-new" },1498 });1499 expect(res).toMatchObject({ ok: true });15001501 expect(await findPairedDeviceById("android-new")).toBeNull();1502 expect(await listPendingVouchedDevices()).toHaveLength(0);1503 });15041505 15061507 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1508 const vault = await import("./vault");1509 await vault.createVault("correct-pass-12345");1510 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1511 await import("./paired-devices");15121513 await upsertPairedDevice({1514 deviceId: "phone-1",1515 deviceName: "June's Phone",1516 deviceType: "android",1517 longTermPublicKeyHex: "a".repeat(64),1518 signingPublicKeyHex: "b".repeat(64),1519 pairedAt: 1_700_000_000_000,1520 trustSource: "direct",1521 });15221523 const res = await dispatch({1524 type: "UNPAIR_DEVICE",1525 payload: { deviceId: "phone-1" },1526 });1527 expect(res).toMatchObject({ ok: true });15281529 1530 expect(await findPairedDeviceById("phone-1")).toBeNull();1531 1532 1533 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1534 const records = await listDeviceRevocations();1535 expect(records).toHaveLength(1);1536 expect(records[0]).toMatchObject({1537 deviceId: "phone-1",1538 longTermPublicKeyHex: "a".repeat(64),1539 revokedByDeviceId: expect.any(String),1540 });1541 });15421543 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1544 const vault = await import("./vault");1545 await vault.createVault("correct-pass-12345");1546 const { listDeviceRevocations } = await import("./paired-devices");15471548 const res = await dispatch({1549 type: "UNPAIR_DEVICE",1550 payload: { deviceId: "never-paired" },1551 });1552 expect(res).toMatchObject({ ok: true });1553 1554 1555 1556 1557 const records = await listDeviceRevocations();1558 expect(records).toHaveLength(1);1559 expect(records[0]).toMatchObject({1560 deviceId: "never-paired",1561 longTermPublicKeyHex: "0".repeat(64),1562 });1563 });15641565 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1566 const res = await dispatch({1567 type: "UNPAIR_DEVICE",1568 payload: { deviceId: "" },1569 });1570 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1571 });15721573 1574 1575 1576 1577 1578 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1579 const vault = await import("./vault");1580 await vault.createVault("correct-pass-12345");1581 const contentScriptSender = {1582 id: "peach-extension",1583 url: "https://evil.example/page",1584 };1585 const extensionPageSender = {1586 id: "peach-extension",1587 url: "chrome-extension://peach-extension/popup.html",1588 };1589 const completion = { requestId: 1, rpId: "evil.example" };15901591 1592 await expect(1593 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1594 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });15951596 1597 1598 await expect(1599 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1600 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1601 });16021603 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1604 const vault = await import("./vault");1605 await vault.createVault("correct-pass-12345");1606 const extensionPageSender = {1607 id: "peach-extension",1608 url: "chrome-extension://peach-extension/popup.html",1609 };1610 const completion = {1611 requestId: 123,1612 rpId: "example.com",1613 rpName: "Example",1614 userName: "june",1615 userHandle: "BBBB",1616 credentialId: "cred-1",1617 publicKeyCose: "c3BraQ",1618 algorithm: -7 as const,1619 };16201621 1622 await expect(1623 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1624 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });16251626 1627 sessionStorage.passkey_pending_firefox = {1628 requestId: 123,1629 type: "create",1630 rpId: "example.com",1631 rpName: "Example",1632 userName: "june",1633 browser: "firefox",1634 challenge: "AAAA",1635 userHandle: "BBBB",1636 };16371638 1639 await expect(1640 dispatch(1641 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1642 extensionPageSender1643 )1644 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });16451646 1647 await expect(1648 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1649 ).resolves.toMatchObject({ ok: true });1650 const entries = await dispatch({ type: "GET_ENTRIES" });1651 expect(entries).toMatchObject({ ok: true });1652 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1653 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1654 });16551656 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1657 await expect(1658 dispatch(1659 {1660 type: "INITIATE_FIREFOX_PASSKEY_CREATE",1661 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1662 },1663 { id: "peach-extension", url: "https://evil.example/page" }1664 )1665 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1666 });16671668 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1669 1670 1671 1672 1673 const vault = await import("./vault");1674 await vault.createVault("correct-pass-12345");1675 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1676 "./paired-devices"1677 );16781679 await upsertPairedDevice({1680 deviceId: "phone-1",1681 deviceName: "June's Phone",1682 deviceType: "android",1683 longTermPublicKeyHex: "a".repeat(64),1684 signingPublicKeyHex: "b".repeat(64),1685 pairedAt: 1_700_000_000_000,1686 trustSource: "direct",1687 });16881689 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1690 await revokeDeviceById("phone-1", "self");1691 1692 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1693 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1694 });16951696 16971698 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1699 const vault = await import("./vault");1700 await vault.createVault("correct-pass-12345");17011702 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1703 expect(res).toMatchObject({ ok: false, error: "password_required" });1704 });17051706 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1707 const vault = await import("./vault");1708 await vault.createVault("correct-pass-12345");17091710 const res = await dispatch({1711 type: "GET_RECOVERY_WORDS",1712 payload: { password: "not-the-password" },1713 });1714 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1715 expect(res.data).toBeUndefined();1716 });17171718 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1719 const vault = await import("./vault");1720 await vault.createVault("correct-pass-12345");17211722 const res = await dispatch({1723 type: "GET_RECOVERY_WORDS",1724 payload: { password: "correct-pass-12345" },1725 });1726 expect(res.ok).toBe(true);1727 expect((res.data as string[]).length).toBe(12);1728 });17291730 it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1731 const vault = await import("./vault");1732 const password = "correct-pass-12345";1733 const popupSender = {1734 id: "peach-extension",1735 url: "chrome-extension://peach-extension/src/popup/index.html",1736 } as chrome.runtime.MessageSender;17371738 await vault.createVault(password);1739 await vault.setupPinUnlock(password, "1234");1740 await vault.lock();17411742 await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1743 ok: true,1744 data: { configured: true, available: true, attemptsRemaining: 3 },1745 });1746 });1747});