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 = { id: "peach-extension" }139 ): Promise<MessageResponse> {140 return await new Promise<MessageResponse>((resolve) => {141 const keepChannelOpen = listener(message, sender, (response) => resolve(response));142 expect(keepChannelOpen).toBe(true);143 });144 }145146 it("GET_ENTRIES when unlocked returns the entry array", async () => {147 const vault = await import("./vault");148 await vault.createVault("correct-pass-12345");149 await vault.addEntry({150 title: "GitHub",151 username: "june",152 password: "secret123",153 url: "https://github.com",154 });155156 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({157 ok: true,158 data: [159 expect.objectContaining({160 title: "GitHub",161 username: "june",162 }),163 ],164 });165 });166167 it("GET_ENTRIES when locked returns a vault locked error", async () => {168 const vault = await import("./vault");169 await vault.createVault("correct-pass-12345");170 vault.lock();171172 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({173 ok: false,174 error: "vault_locked",175 });176 });177178 it("ADD_ENTRY with a valid payload returns the created entry", async () => {179 const vault = await import("./vault");180 await vault.createVault("correct-pass-12345");181182 const response = await dispatch({183 type: "ADD_ENTRY",184 payload: {185 title: "GitHub",186 username: "june",187 password: "secret123",188 url: "https://github.com",189 },190 });191192 expect(response).toMatchObject({193 ok: true,194 data: expect.objectContaining({195 id: expect.any(String),196 title: "GitHub",197 }),198 });199 expect(vault.getEntries()).toHaveLength(1);200 });201202 it("ADD_ENTRY when locked returns a vault locked error", async () => {203 const vault = await import("./vault");204 await vault.createVault("correct-pass-12345");205 vault.lock();206207 await expect(208 dispatch({209 type: "ADD_ENTRY",210 payload: {211 title: "GitHub",212 username: "june",213 password: "secret123",214 url: "https://github.com",215 },216 })217 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });218 });219220 it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {221 const vault = await import("./vault");222 await vault.createVault("correct-pass-12345");223224 await expect(225 dispatch({226 type: "ADD_ENTRY",227 payload: {228 title: "GitHub",229 username: "june",230 password: "",231 url: "https://github.com",232 },233 })234 ).resolves.toMatchObject({ ok: true });235 });236237 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {238 const vault = await import("./vault");239 await vault.createVault("correct-pass-12345");240 const entry = await vault.addEntry({241 title: "GitHub",242 username: "june",243 password: "secret123",244 url: "https://github.com",245 });246247 await expect(248 dispatch({249 type: "UPDATE_ENTRY",250 payload: { id: entry.id, patch: { password: "new-secret" } },251 })252 ).resolves.toMatchObject({ ok: true });253254 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");255 });256257 it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {258 const vault = await import("./vault");259 await vault.createVault("correct-pass-12345");260 const entry = await vault.addEntry({261 title: "GitHub",262 username: "june",263 password: "secret123",264 url: "https://github.com",265 });266267 await expect(268 dispatch({269 type: "UPDATE_ENTRY",270 payload: { id: entry.id, patch: { password: "" } },271 })272 ).resolves.toMatchObject({ ok: true });273274 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();275 });276277 it("DELETE_ENTRY soft-deletes the target entry", async () => {278 const vault = await import("./vault");279 await vault.createVault("correct-pass-12345");280 const entry = await vault.addEntry({281 title: "GitHub",282 username: "june",283 password: "secret123",284 url: "https://github.com",285 });286287 await expect(288 dispatch({289 type: "DELETE_ENTRY",290 payload: { id: entry.id },291 })292 ).resolves.toMatchObject({ ok: true });293294 expect(vault.getEntries()).toEqual([]);295 expect(vault.getDeletedEntries()).toHaveLength(1);296 });297298 it("DELETE_ENTRY of a missing id returns an error", async () => {299 const vault = await import("./vault");300 await vault.createVault("correct-pass-12345");301302 await expect(303 dispatch({304 type: "DELETE_ENTRY",305 payload: { id: "missing" },306 })307 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });308 });309310 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {311 const vault = await import("./vault");312 await vault.createVault("correct-pass-12345");313 const entry = await vault.addEntry({314 title: "GitHub",315 username: "june",316 password: "secret123",317 url: "https://github.com",318 });319320 const response = await dispatch({321 type: "GET_ENTRY_FOR_FILL",322 payload: { id: entry.id },323 });324325 expect(response).toMatchObject({326 ok: true,327 data: expect.objectContaining({328 id: entry.id,329 username: "june",330 password: "secret123",331 }),332 });333 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();334 });335336 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {337 const vault = await import("./vault");338 await vault.createVault("correct-pass-12345");339 vault.lock();340341 await expect(342 dispatch({343 type: "GET_ENTRY_FOR_FILL",344 payload: { id: "missing" },345 })346 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });347 });348349 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {350 const vault = await import("./vault");351 await vault.createVault("correct-pass-12345");352 const generated = await generatePasskeyKeypair(-7);353 const entry = await vault.addEntry({354 type: "login",355 title: "WebAuthn.io",356 passkey: {357 rpId: "webauthn.io",358 rpName: "WebAuthn.io",359 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),360 publicKeyCose: base64urlEncode(generated.publicKeyCose),361 privateKeyJwk: generated.privateKeyJwk,362 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),363 userName: "june",364 userDisplayName: "June",365 algorithm: -7,366 signCount: 0,367 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",368 backupEligible: true,369 backupState: false,370 createdAt: Date.now(),371 },372 });373374 await expect(375 dispatch({376 type: "GET_ENTRY_FOR_FILL",377 payload: { id: entry.id },378 })379 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });380 });381382 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {383 const vault = await import("./vault");384 await vault.createVault("correct-pass-12345");385 await vault.addEntry({386 title: "GitHub",387 username: "june",388 password: "secret123",389 url: "https://github.com",390 totp: "JBSWY3DPEHPK3PXP",391 });392393 const response = await dispatch({394 type: "AUTOFILL_QUERY",395 payload: { domain: "github.com" },396 });397398 expect(response).toMatchObject({399 ok: true,400 data: [401 {402 id: expect.any(String),403 title: "GitHub",404 username: "june",405 url: "https://github.com",406 hasTotp: true,407 },408 ],409 });410 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();411 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();412 });413414 it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {415 const vault = await import("./vault");416 await vault.createVault("correct-pass-12345");417 await vault.addEntry({418 title: "GitHub",419 username: "june",420 password: "secret123",421 url: "https://github.com",422 });423 await vault.addEntry({424 type: "note",425 title: "Secure note",426 notes: "hello",427 });428429 const response = await dispatch({430 type: "AUTOFILL_QUERY",431 payload: { domain: "github.com", includeAll: true },432 });433434 expect(response).toMatchObject({435 ok: true,436 data: [437 {438 id: expect.any(String),439 title: "GitHub",440 username: "june",441 url: "https://github.com",442 hasPasskey: false,443 },444 ],445 });446 const data = response.data as Array<Record<string, unknown>>;447 expect(data).toHaveLength(1);448 449 expect(data[0]).not.toHaveProperty("password");450 expect(data[0]).not.toHaveProperty("totp");451 });452453 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {454 const vault = await import("./vault");455 await vault.createVault("correct-pass-12345");456 await vault.addEntry({457 title: "GitHub",458 username: "june",459 password: "secret123",460 url: "https://github.com",461 totp: "JBSWY3DPEHPK3PXP",462 });463 await vault.addEntry({464 title: "No 2FA",465 username: "person",466 password: "secret123",467 url: "https://github.com",468 });469470 const response = await dispatch({471 type: "AUTOFILL_QUERY",472 payload: { domain: "github.com", kind: "totp" },473 });474475 expect(response).toMatchObject({476 ok: true,477 data: [478 {479 id: expect.any(String),480 title: "GitHub",481 username: "june",482 url: "https://github.com",483 hasTotp: true,484 },485 ],486 });487 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();488 expect(response.data).toHaveLength(1);489 });490491 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {492 const vault = await import("./vault");493 await vault.createVault("correct-pass-12345");494 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });495496 const response = await dispatch({497 type: "RESTORE_ENTRIES",498 payload: {499 mode: "replace",500 entries: [501 {502 id: "restored-1",503 type: "login",504 title: "B",505 username: "june",506 password: "two",507 url: "https://b.test",508 createdAt: 1,509 modifiedAt: 1,510 },511 ],512 },513 });514515 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });516 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);517 });518519 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {520 const vault = await import("./vault");521 await vault.createVault("correct-pass-12345");522523 await expect(524 dispatch({525 type: "RESTORE_ENTRIES",526 payload: { mode: "replace", entries: [] },527 })528 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });529 });530531 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {532 const vault = await import("./vault");533 await vault.createVault("correct-pass-12345");534535 await expect(536 dispatch({537 type: "CHANGE_PASSWORD",538 payload: {539 currentPassword: "correct-pass-12345",540 newPassword: "new-pass-67890",541 },542 })543 ).resolves.toMatchObject({ ok: true });544545 vault.lock();546 expect(await vault.unlock("new-pass-67890")).toBe(true);547548 await expect(549 dispatch({550 type: "CHANGE_PASSWORD",551 payload: {552 currentPassword: "wrong-pass",553 newPassword: "another-pass",554 },555 })556 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });557 });558559 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {560 const vault = await import("./vault");561 await vault.createVault("correct-pass-12345");562563 (564 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>565 ).mockClear();566 (567 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>568 ).mockClear();569570 await expect(571 dispatch({572 type: "UPDATE_SETTINGS",573 payload: { autoLockMinutes: 30 },574 })575 ).resolves.toMatchObject({ ok: true });576577 expect(vault.getSettings().autoLockMinutes).toBe(30);578 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);579 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {580 delayInMinutes: 30,581 });582 });583584 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {585 vi.useFakeTimers();586 const vault = await import("./vault");587 await vault.createVault("correct-pass-12345");588 const entry = await vault.addEntry({589 title: "GitHub",590 username: "june",591 password: "secret123",592 url: "https://github.com",593 });594 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);595596 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });597 await vi.runAllTimersAsync();598 const response = await responsePromise;599600 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });601 expect(vault.getEntryById(entry.id)?.breach).toBe(7);602 breachSpy.mockRestore();603 });604605 it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {606 const vault = await import("./vault");607 await vault.createVault("correct-pass-12345");608609 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({610 ok: false,611 error: "invalid_sender",612 });613614 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({615 ok: true,616 data: [],617 });618 });619620 it("LOCK and UNLOCK messages update vault state correctly", async () => {621 const vault = await import("./vault");622623 await expect(624 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })625 ).resolves.toMatchObject({ ok: true });626 expect(vault.isUnlocked()).toBe(true);627628 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });629 expect(vault.isUnlocked()).toBe(false);630631 await expect(632 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })633 ).resolves.toMatchObject({ ok: false });634 expect(vault.isUnlocked()).toBe(false);635636 await expect(637 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })638 ).resolves.toMatchObject({ ok: true });639 expect(vault.isUnlocked()).toBe(true);640 });641642 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {643 const response = await dispatch({644 type: "FIRE_CANARY_PING",645 payload: {646 webhookUrl: "https://example.com/hook",647 entryTitle: "GitHub",648 trigger: "password_copied",649 },650 });651652 expect(response).toMatchObject({ ok: true });653 expect(fetchMock).toHaveBeenCalledWith(654 "https://example.com/hook",655 expect.objectContaining({656 method: "POST",657 headers: { "Content-Type": "application/json" },658 body: expect.any(String),659 })660 );661 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;662 expect(body).toMatchObject({663 entry: "GitHub",664 trigger: "password_copied",665 source: "peach-extension",666 });667 });668669 it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {670 const payload = {671 webhookUrl: "https://example.com/hook",672 entryTitle: "GitHub",673 trigger: "autofilled" as const,674 };675 const first = await dispatch({ type: "FIRE_CANARY_PING", payload });676 expect(first).toMatchObject({ ok: true });677 expect(fetchMock).toHaveBeenCalledTimes(1);678679 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });680 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });681 expect(fetchMock).toHaveBeenCalledTimes(1); 682 });683684 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {685 await dispatch({686 type: "FIRE_CANARY_PING",687 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },688 });689 await dispatch({690 type: "FIRE_CANARY_PING",691 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },692 });693 expect(fetchMock).toHaveBeenCalledTimes(2);694 });695696 it("LV-3: test pings are exempt from the debounce", async () => {697 const payload = {698 webhookUrl: "https://example.com/hook",699 entryTitle: "GitHub",700 trigger: "test" as const,701 };702 await dispatch({ type: "FIRE_CANARY_PING", payload });703 await dispatch({ type: "FIRE_CANARY_PING", payload });704 expect(fetchMock).toHaveBeenCalledTimes(2);705 });706707 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {708 await expect(709 dispatch({710 type: "FIRE_CANARY_PING",711 payload: {712 webhookUrl: "http://example.com/hook",713 entryTitle: "GitHub",714 trigger: "password_copied",715 },716 })717 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });718719 expect(fetchMock).not.toHaveBeenCalled();720 });721722 it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {723 for (const webhookUrl of [724 "https://[::ffff:192.168.0.1]/hook",725 "https://[::ffff:c0a8:1]/hook",726 "https://[::ffff:10.0.0.1]/hook",727 "https://[::ffff:127.0.0.1]/hook",728 "https://[::ffff:169.254.1.1]/hook",729 "https://[::ffff:172.16.0.1]/hook",730 ]) {731 await expect(732 dispatch({733 type: "FIRE_CANARY_PING",734 payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },735 })736 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });737 }738739 expect(fetchMock).not.toHaveBeenCalled();740 });741742 it("GET_IDENTITIES when locked returns vault locked error", async () => {743 const vault = await import("./vault");744 await vault.createVault("correct-pass-12345");745 vault.lock();746 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({747 ok: false,748 error: "vault_locked",749 });750 });751752 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {753 const vault = await import("./vault");754 await vault.createVault("correct-pass-12345");755 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });756 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });757 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });758759 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };760 expect(resp.ok).toBe(true);761 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;762 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({763 type: "email",764 useCount: 2,765 });766 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({767 type: "username",768 useCount: 1,769 });770 771 const emailIdx = data.findIndex((d) => d.value === "[email protected]");772 const userIdx = data.findIndex((d) => d.value === "juni_patel");773 expect(emailIdx).toBeLessThan(userIdx);774 });775776 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {777 const vault = await import("./vault");778 await vault.createVault("correct-pass-12345");779 for (let i = 0; i < 3; i++) {780 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });781 }782783 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };784 const data = resp.data as Array<{ value: string; useCount: number }>;785 const hits = data.filter((d) => d.value === "[email protected]");786 expect(hits).toHaveLength(1);787 expect(hits[0]!.useCount).toBe(3);788 });789790 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {791 const vault = await import("./vault");792 await vault.createVault("correct-pass-12345");793 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;794795 796 alarmsCreate.mockClear();797 await dispatch({ type: "GET_ENTRIES" });798 expect(alarmsCreate).toHaveBeenCalled();799800 801 alarmsCreate.mockClear();802 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });803 expect(alarmsCreate).not.toHaveBeenCalled();804 });805806 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {807 const vault = await import("./vault");808 await vault.createVault("correct-pass-12345");809 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });810811 812 storage.peach_license = "PEACH-PRO-TEST-KEY";813 storage.peach_pro_celebrated = true;814815 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });816 expect(res).toMatchObject({ ok: true });817818 819 expect(storage.peach_vault_a).toBeUndefined();820 821 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");822 expect(storage.peach_pro_celebrated).toBe(true);823 });824825 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {826 const messages = await import("./messages");827 const vault = await import("./vault");828829 let resolveGate: () => void = () => {};830 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));831832 let settled = false;833 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });834835 836 837 await new Promise((r) => setTimeout(r, 20));838 expect(settled).toBe(false);839840 841 await vault.createVault("correct-pass-12345");842 resolveGate();843844 await expect(pending).resolves.toMatchObject({ ok: true, data: true });845 });846847 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {848 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });849 expect(res.ok).toBe(false);850 851 const has = await dispatch({ type: "HAS_VAULT" });852 expect((has as { data?: boolean }).data).toBe(false);853 });854855 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {856 const vault = await import("./vault");857 await vault.createVault("correct-pass-12345");858 const e = await vault.addEntry({859 title: "Mail",860 username: "june",861 password: "pw",862 url: "https://mail.test",863 totp: "JBSWY3DPEHPK3PXP",864 });865866 const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });867 expect(res.ok).toBe(true);868 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };869 expect(data.totp).toBeUndefined();870 expect(data.totpCode).toMatch(/^\d{6}$/);871 expect(data.totpRemaining).toBeGreaterThan(0);872 expect(data.totpRemaining).toBeLessThanOrEqual(30);873 });874875 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {876 const vault = await import("./vault");877 await vault.createVault("correct-pass-12345");878 const e = await vault.addEntry({879 title: "Bank",880 username: "june",881 password: "old-pw",882 url: "https://bank.test",883 });884 await vault.updateEntry(e.id, { password: "new-pw" });885886 const fill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });887 expect(fill.ok).toBe(true);888 889 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();890891 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });892 expect(edit.ok).toBe(true);893 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];894 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);895 });896897 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {898 const vault = await import("./vault");899 await vault.createVault("correct-pass-12345");900 const e = await vault.addEntry({901 title: "Mail",902 username: "june",903 password: "pw",904 url: "https://mail.test",905 totp: "JBSWY3DPEHPK3PXP",906 });907908 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });909 expect(res.ok).toBe(true);910 const data = res.data as { totp?: string };911 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");912 });913914 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {915 const vault = await import("./vault");916 await vault.createVault("correct-pass-12345");917918 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });919 expect(res).toMatchObject({ ok: true });920921 expect(storage.peach_vault_a).toBeUndefined();922 expect(storage.peach_license).toBeUndefined();923 expect(storage.peach_pro_celebrated).toBeUndefined();924 });925926 927928 function tabSender(url: string): chrome.runtime.MessageSender {929 return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };930 }931932 it("MV-1: tab request with a matching domain returns the entry", async () => {933 const vault = await import("./vault");934 await vault.createVault("correct-pass-12345");935 const entry = await vault.addEntry({936 title: "GitHub",937 username: "june",938 password: "secret123",939 url: "https://github.com",940 });941942 const res = await dispatch(943 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },944 tabSender("https://github.com/login"),945 );946 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });947 });948949 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {950 const vault = await import("./vault");951 await vault.createVault("correct-pass-12345");952 const entry = await vault.addEntry({953 title: "GitHub",954 username: "june",955 password: "secret123",956 url: "https://github.com",957 });958959 const res = await dispatch(960 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },961 tabSender("https://evil.example.com/phish"),962 );963 964 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });965 });966967 it("MV-1: mismatched domain with explicitSummon:true returns the entry (user-chosen off-domain fill)", async () => {968 const vault = await import("./vault");969 await vault.createVault("correct-pass-12345");970 const entry = await vault.addEntry({971 title: "GitHub",972 username: "june",973 password: "secret123",974 url: "https://github.com",975 });976977 const res = await dispatch(978 {979 type: "GET_ENTRY_FOR_FILL",980 payload: { id: entry.id, explicitSummon: true },981 },982 tabSender("https://evil.example.com/phish"),983 );984 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });985 });986987 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {988 const vault = await import("./vault");989 await vault.createVault("correct-pass-12345");990 const entry = await vault.addEntry({991 title: "GitHub",992 username: "june",993 password: "secret123",994 url: "https://github.com",995 });996997 const res = await dispatch(998 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },999 tabSender("not a url"),1000 );1001 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1002 });10031004 10051006 async function seedTwoDomainVault() {1007 const vault = await import("./vault");1008 await vault.createVault("correct-pass-12345");1009 await vault.addEntry({1010 title: "GitHub",1011 username: "june",1012 password: "gh",1013 url: "https://github.com",1014 });1015 await vault.addEntry({1016 title: "Example",1017 username: "june",1018 password: "ex",1019 url: "https://example.com",1020 });1021 }10221023 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1024 await seedTwoDomainVault();10251026 const res = await dispatch({1027 type: "AUTOFILL_QUERY",1028 payload: { domain: "github.com", includeAll: true },1029 });1030 expect(res.ok).toBe(true);1031 const data = res.data as Array<{ title: string }>;1032 1033 expect(data).toHaveLength(1);1034 expect(data[0].title).toBe("GitHub");1035 });10361037 it("MV-2: AUTOFILL_QUERY with includeAll + userGesture returns the vault-wide list", async () => {1038 await seedTwoDomainVault();10391040 const res = await dispatch({1041 type: "AUTOFILL_QUERY",1042 payload: { domain: "github.com", includeAll: true, userGesture: true },1043 });1044 expect(res.ok).toBe(true);1045 const data = res.data as Array<{ title: string }>;1046 expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);1047 });10481049 10501051 async function seedPendingVouched(deviceId: string) {1052 const { addPendingVouchedDevice } = await import(1053 "./pending-vouched-devices"1054 );1055 await addPendingVouchedDevice({1056 deviceId,1057 deviceName: "New Pixel",1058 deviceType: "android",1059 longTermPublicKeyHex: "e".repeat(64),1060 signingPublicKeyHex: "a".repeat(64),1061 wsPort: 51234,1062 pairedAt: 1_700_000_000_020,1063 receivedAt: 1_700_000_000_050,1064 vouchedByDeviceId: "extension-issuer",1065 });1066 }10671068 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1069 const vault = await import("./vault");1070 await vault.createVault("correct-pass-12345");1071 await seedPendingVouched("android-new");1072 const { findPairedDeviceById } = await import("./paired-devices");1073 const { listPendingVouchedDevices } = await import(1074 "./pending-vouched-devices"1075 );10761077 const res = await dispatch({1078 type: "CONFIRM_VOUCHED_DEVICE",1079 payload: { deviceId: "android-new" },1080 });1081 expect(res).toMatchObject({ ok: true });10821083 expect(await findPairedDeviceById("android-new")).toMatchObject({1084 deviceName: "New Pixel",1085 trustSource: "vouched",1086 longTermPublicKeyHex: "e".repeat(64),1087 });1088 expect(await listPendingVouchedDevices()).toHaveLength(0);1089 });10901091 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1092 const vault = await import("./vault");1093 await vault.createVault("correct-pass-12345");1094 await seedPendingVouched("android-relabelled");1095 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1096 await import("./paired-devices");1097 const { listPendingVouchedDevices } = await import(1098 "./pending-vouched-devices"1099 );11001101 1102 1103 await upsertPairedDevice({1104 deviceId: "android-old",1105 deviceName: "New Pixel",1106 deviceType: "android",1107 longTermPublicKeyHex: "e".repeat(64),1108 signingPublicKeyHex: "a".repeat(64),1109 pairedAt: 1_700_000_000_000,1110 trustSource: "vouched",1111 });1112 await revokeDeviceById("android-old", "self");11131114 const res = await dispatch({1115 type: "CONFIRM_VOUCHED_DEVICE",1116 payload: { deviceId: "android-relabelled" },1117 });1118 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1119 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1120 expect(await listPendingVouchedDevices()).toHaveLength(0);1121 });11221123 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1124 const vault = await import("./vault");1125 await vault.createVault("correct-pass-12345");1126 await seedPendingVouched("android-new");1127 const { findPairedDeviceById } = await import("./paired-devices");1128 const { listPendingVouchedDevices } = await import(1129 "./pending-vouched-devices"1130 );11311132 const res = await dispatch({1133 type: "REJECT_VOUCHED_DEVICE",1134 payload: { deviceId: "android-new" },1135 });1136 expect(res).toMatchObject({ ok: true });11371138 expect(await findPairedDeviceById("android-new")).toBeNull();1139 expect(await listPendingVouchedDevices()).toHaveLength(0);1140 });11411142 11431144 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1145 const vault = await import("./vault");1146 await vault.createVault("correct-pass-12345");1147 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1148 await import("./paired-devices");11491150 await upsertPairedDevice({1151 deviceId: "phone-1",1152 deviceName: "June's Phone",1153 deviceType: "android",1154 longTermPublicKeyHex: "a".repeat(64),1155 signingPublicKeyHex: "b".repeat(64),1156 pairedAt: 1_700_000_000_000,1157 trustSource: "direct",1158 });11591160 const res = await dispatch({1161 type: "UNPAIR_DEVICE",1162 payload: { deviceId: "phone-1" },1163 });1164 expect(res).toMatchObject({ ok: true });11651166 1167 expect(await findPairedDeviceById("phone-1")).toBeNull();1168 1169 1170 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1171 const records = await listDeviceRevocations();1172 expect(records).toHaveLength(1);1173 expect(records[0]).toMatchObject({1174 deviceId: "phone-1",1175 longTermPublicKeyHex: "a".repeat(64),1176 revokedByDeviceId: expect.any(String),1177 });1178 });11791180 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1181 const vault = await import("./vault");1182 await vault.createVault("correct-pass-12345");1183 const { listDeviceRevocations } = await import("./paired-devices");11841185 const res = await dispatch({1186 type: "UNPAIR_DEVICE",1187 payload: { deviceId: "never-paired" },1188 });1189 expect(res).toMatchObject({ ok: true });1190 1191 1192 1193 1194 const records = await listDeviceRevocations();1195 expect(records).toHaveLength(1);1196 expect(records[0]).toMatchObject({1197 deviceId: "never-paired",1198 longTermPublicKeyHex: "0".repeat(64),1199 });1200 });12011202 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1203 const res = await dispatch({1204 type: "UNPAIR_DEVICE",1205 payload: { deviceId: "" },1206 });1207 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1208 });12091210 it("a fresh direct re-pair clears the old revocation before restoring the row", async () => {1211 const vault = await import("./vault");1212 await vault.createVault("correct-pass-12345");1213 const {1214 findPairedDeviceById,1215 isDeviceRevoked,1216 upsertPairedDevice,1217 } = await import("./paired-devices");1218 const phone = {1219 deviceId: "phone-1",1220 deviceName: "June's Phone",1221 deviceType: "android" as const,1222 longTermPublicKeyHex: "a".repeat(64),1223 signingPublicKeyHex: "b".repeat(64),1224 pairedAt: 1_700_000_000_000,1225 trustSource: "direct" as const,1226 };1227 await upsertPairedDevice(phone);1228 await dispatch({1229 type: "UNPAIR_DEVICE",1230 payload: { deviceId: phone.deviceId },1231 });1232 expect(await isDeviceRevoked(phone.deviceId, phone.longTermPublicKeyHex)).toBe(true);12331234 await expect(dispatch({1235 type: "COMPLETE_PAIRING",1236 payload: { paired: phone },1237 }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" }))1238 .resolves.toMatchObject({ ok: true });12391240 expect(await isDeviceRevoked(phone.deviceId, phone.longTermPublicKeyHex)).toBe(false);1241 expect(await findPairedDeviceById(phone.deviceId)).toMatchObject(phone);1242 });12431244 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1245 const vault = await import("./vault");1246 await vault.createVault("correct-pass-12345");1247 const contentScriptSender = {1248 id: "peach-extension",1249 url: "https://evil.example/page",1250 };1251 const extensionPageSender = {1252 id: "peach-extension",1253 url: "chrome-extension://peach-extension/popup.html",1254 };1255 const paired = {1256 deviceId: "attacker-1",1257 deviceName: "Attacker Device",1258 deviceType: "android" as const,1259 longTermPublicKeyHex: "c".repeat(64),1260 signingPublicKeyHex: "d".repeat(64),1261 pairedAt: 1_700_000_000_000,1262 trustSource: "direct" as const,1263 };12641265 1266 await expect(1267 dispatch({ type: "GET_ADOPTION_MATERIAL" }, contentScriptSender)1268 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });1269 await expect(1270 dispatch({ type: "COMPLETE_PAIRING", payload: { paired } }, contentScriptSender)1271 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });1272 await expect(1273 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { requestId: 1, rpId: "evil.example" } }, contentScriptSender)1274 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });12751276 1277 const { findPairedDeviceById } = await import("./paired-devices");1278 expect(await findPairedDeviceById(paired.deviceId)).toBeNull();12791280 1281 await expect(1282 dispatch({ type: "GET_ADOPTION_MATERIAL" }, extensionPageSender)1283 ).resolves.toMatchObject({ ok: true });1284 });12851286 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1287 const vault = await import("./vault");1288 await vault.createVault("correct-pass-12345");1289 const extensionPageSender = {1290 id: "peach-extension",1291 url: "chrome-extension://peach-extension/popup.html",1292 };1293 const completion = {1294 requestId: 123,1295 rpId: "example.com",1296 rpName: "Example",1297 userName: "june",1298 userHandle: "BBBB",1299 credentialId: "cred-1",1300 publicKeyCose: "c3BraQ",1301 algorithm: -7 as const,1302 };13031304 1305 await expect(1306 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1307 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });13081309 1310 sessionStorage["passkey_pending_firefox"] = {1311 requestId: 123,1312 type: "create",1313 rpId: "example.com",1314 rpName: "Example",1315 userName: "june",1316 browser: "firefox",1317 challenge: "AAAA",1318 userHandle: "BBBB",1319 };13201321 1322 await expect(1323 dispatch(1324 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1325 extensionPageSender1326 )1327 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });13281329 1330 await expect(1331 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1332 ).resolves.toMatchObject({ ok: true });1333 const entries = await dispatch({ type: "GET_ENTRIES" });1334 expect(entries).toMatchObject({ ok: true });1335 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1336 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1337 });13381339 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1340 await expect(1341 dispatch(1342 {1343 type: "INITIATE_FIREFOX_PASSKEY_CREATE",1344 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1345 },1346 { id: "peach-extension", url: "https://evil.example/page" }1347 )1348 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1349 });13501351 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1352 1353 1354 1355 1356 const vault = await import("./vault");1357 await vault.createVault("correct-pass-12345");1358 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1359 "./paired-devices"1360 );13611362 await upsertPairedDevice({1363 deviceId: "phone-1",1364 deviceName: "June's Phone",1365 deviceType: "android",1366 longTermPublicKeyHex: "a".repeat(64),1367 signingPublicKeyHex: "b".repeat(64),1368 pairedAt: 1_700_000_000_000,1369 trustSource: "direct",1370 });13711372 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1373 await revokeDeviceById("phone-1", "self");1374 1375 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1376 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1377 });13781379 13801381 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1382 const vault = await import("./vault");1383 await vault.createVault("correct-pass-12345");13841385 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1386 expect(res).toMatchObject({ ok: false, error: "password_required" });1387 });13881389 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1390 const vault = await import("./vault");1391 await vault.createVault("correct-pass-12345");13921393 const res = await dispatch({1394 type: "GET_RECOVERY_WORDS",1395 payload: { password: "not-the-password" },1396 });1397 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1398 expect(res.data).toBeUndefined();1399 });14001401 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1402 const vault = await import("./vault");1403 await vault.createVault("correct-pass-12345");14041405 const res = await dispatch({1406 type: "GET_RECOVERY_WORDS",1407 payload: { password: "correct-pass-12345" },1408 });1409 expect(res.ok).toBe(true);1410 expect((res.data as string[]).length).toBe(12);1411 });1412});