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("remembers the last successful unlock method", async () => {643 const vault = await import("./vault");644 const password = "correct-pass-12345";645 const popupSender = {646 id: "peach-extension",647 url: "chrome-extension://peach-extension/src/popup/index.html",648 } as chrome.runtime.MessageSender;649650 await vault.createVault(password);651 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);652 vault.lock();653654 await expect(655 dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),656 ).resolves.toMatchObject({ ok: true });657 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);658659 vault.lock();660 await expect(661 dispatch({ type: "UNLOCK", payload: { password } }, popupSender),662 ).resolves.toMatchObject({ ok: true });663 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);664 });665666 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {667 const response = await dispatch({668 type: "FIRE_CANARY_PING",669 payload: {670 webhookUrl: "https://example.com/hook",671 entryTitle: "GitHub",672 trigger: "password_copied",673 },674 });675676 expect(response).toMatchObject({ ok: true });677 expect(fetchMock).toHaveBeenCalledWith(678 "https://example.com/hook",679 expect.objectContaining({680 method: "POST",681 headers: { "Content-Type": "application/json" },682 body: expect.any(String),683 })684 );685 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;686 expect(body).toMatchObject({687 entry: "GitHub",688 trigger: "password_copied",689 source: "peach-extension",690 });691 });692693 it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {694 const payload = {695 webhookUrl: "https://example.com/hook",696 entryTitle: "GitHub",697 trigger: "autofilled" as const,698 };699 const first = await dispatch({ type: "FIRE_CANARY_PING", payload });700 expect(first).toMatchObject({ ok: true });701 expect(fetchMock).toHaveBeenCalledTimes(1);702703 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });704 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });705 expect(fetchMock).toHaveBeenCalledTimes(1); 706 });707708 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {709 await dispatch({710 type: "FIRE_CANARY_PING",711 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },712 });713 await dispatch({714 type: "FIRE_CANARY_PING",715 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },716 });717 expect(fetchMock).toHaveBeenCalledTimes(2);718 });719720 it("LV-3: test pings are exempt from the debounce", async () => {721 const payload = {722 webhookUrl: "https://example.com/hook",723 entryTitle: "GitHub",724 trigger: "test" as const,725 };726 await dispatch({ type: "FIRE_CANARY_PING", payload });727 await dispatch({ type: "FIRE_CANARY_PING", payload });728 expect(fetchMock).toHaveBeenCalledTimes(2);729 });730731 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {732 await expect(733 dispatch({734 type: "FIRE_CANARY_PING",735 payload: {736 webhookUrl: "http://example.com/hook",737 entryTitle: "GitHub",738 trigger: "password_copied",739 },740 })741 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });742743 expect(fetchMock).not.toHaveBeenCalled();744 });745746 it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {747 for (const webhookUrl of [748 "https://[::ffff:192.168.0.1]/hook",749 "https://[::ffff:c0a8:1]/hook",750 "https://[::ffff:10.0.0.1]/hook",751 "https://[::ffff:127.0.0.1]/hook",752 "https://[::ffff:169.254.1.1]/hook",753 "https://[::ffff:172.16.0.1]/hook",754 ]) {755 await expect(756 dispatch({757 type: "FIRE_CANARY_PING",758 payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },759 })760 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });761 }762763 expect(fetchMock).not.toHaveBeenCalled();764 });765766 it("GET_IDENTITIES when locked returns vault locked error", async () => {767 const vault = await import("./vault");768 await vault.createVault("correct-pass-12345");769 vault.lock();770 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({771 ok: false,772 error: "vault_locked",773 });774 });775776 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {777 const vault = await import("./vault");778 await vault.createVault("correct-pass-12345");779 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });780 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });781 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });782783 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };784 expect(resp.ok).toBe(true);785 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;786 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({787 type: "email",788 useCount: 2,789 });790 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({791 type: "username",792 useCount: 1,793 });794 795 const emailIdx = data.findIndex((d) => d.value === "[email protected]");796 const userIdx = data.findIndex((d) => d.value === "juni_patel");797 expect(emailIdx).toBeLessThan(userIdx);798 });799800 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {801 const vault = await import("./vault");802 await vault.createVault("correct-pass-12345");803 for (let i = 0; i < 3; i++) {804 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });805 }806807 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };808 const data = resp.data as Array<{ value: string; useCount: number }>;809 const hits = data.filter((d) => d.value === "[email protected]");810 expect(hits).toHaveLength(1);811 expect(hits[0]!.useCount).toBe(3);812 });813814 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {815 const vault = await import("./vault");816 await vault.createVault("correct-pass-12345");817 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;818819 820 alarmsCreate.mockClear();821 await dispatch({ type: "GET_ENTRIES" });822 expect(alarmsCreate).toHaveBeenCalled();823824 825 alarmsCreate.mockClear();826 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });827 expect(alarmsCreate).not.toHaveBeenCalled();828 });829830 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {831 const vault = await import("./vault");832 await vault.createVault("correct-pass-12345");833 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });834835 836 storage.peach_license = "PEACH-PRO-TEST-KEY";837 storage.peach_pro_celebrated = true;838839 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });840 expect(res).toMatchObject({ ok: true });841842 843 expect(storage.peach_vault_a).toBeUndefined();844 845 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");846 expect(storage.peach_pro_celebrated).toBe(true);847 });848849 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {850 const messages = await import("./messages");851 const vault = await import("./vault");852853 let resolveGate: () => void = () => {};854 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));855856 let settled = false;857 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });858859 860 861 await new Promise((r) => setTimeout(r, 20));862 expect(settled).toBe(false);863864 865 await vault.createVault("correct-pass-12345");866 resolveGate();867868 await expect(pending).resolves.toMatchObject({ ok: true, data: true });869 });870871 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {872 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });873 expect(res.ok).toBe(false);874 875 const has = await dispatch({ type: "HAS_VAULT" });876 expect((has as { data?: boolean }).data).toBe(false);877 });878879 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {880 const vault = await import("./vault");881 await vault.createVault("correct-pass-12345");882 const e = await vault.addEntry({883 title: "Mail",884 username: "june",885 password: "pw",886 url: "https://mail.test",887 totp: "JBSWY3DPEHPK3PXP",888 });889890 const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });891 expect(res.ok).toBe(true);892 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };893 expect(data.totp).toBeUndefined();894 expect(data.totpCode).toMatch(/^\d{6}$/);895 expect(data.totpRemaining).toBeGreaterThan(0);896 expect(data.totpRemaining).toBeLessThanOrEqual(30);897 });898899 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {900 const vault = await import("./vault");901 await vault.createVault("correct-pass-12345");902 const e = await vault.addEntry({903 title: "Bank",904 username: "june",905 password: "old-pw",906 url: "https://bank.test",907 });908 await vault.updateEntry(e.id, { password: "new-pw" });909910 const fill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });911 expect(fill.ok).toBe(true);912 913 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();914915 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });916 expect(edit.ok).toBe(true);917 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];918 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);919 });920921 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {922 const vault = await import("./vault");923 await vault.createVault("correct-pass-12345");924 const e = await vault.addEntry({925 title: "Mail",926 username: "june",927 password: "pw",928 url: "https://mail.test",929 totp: "JBSWY3DPEHPK3PXP",930 });931932 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });933 expect(res.ok).toBe(true);934 const data = res.data as { totp?: string };935 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");936 });937938 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {939 const vault = await import("./vault");940 await vault.createVault("correct-pass-12345");941942 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });943 expect(res).toMatchObject({ ok: true });944945 expect(storage.peach_vault_a).toBeUndefined();946 expect(storage.peach_license).toBeUndefined();947 expect(storage.peach_pro_celebrated).toBeUndefined();948 });949950 951952 function tabSender(url: string): chrome.runtime.MessageSender {953 return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };954 }955956 it("MV-1: tab request with a matching domain returns the entry", async () => {957 const vault = await import("./vault");958 await vault.createVault("correct-pass-12345");959 const entry = await vault.addEntry({960 title: "GitHub",961 username: "june",962 password: "secret123",963 url: "https://github.com",964 });965966 const res = await dispatch(967 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },968 tabSender("https://github.com/login"),969 );970 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });971 });972973 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {974 const vault = await import("./vault");975 await vault.createVault("correct-pass-12345");976 const entry = await vault.addEntry({977 title: "GitHub",978 username: "june",979 password: "secret123",980 url: "https://github.com",981 });982983 const res = await dispatch(984 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },985 tabSender("https://evil.example.com/phish"),986 );987 988 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });989 });990991 it("MV-1: mismatched domain with explicitSummon:true returns the entry (user-chosen off-domain fill)", async () => {992 const vault = await import("./vault");993 await vault.createVault("correct-pass-12345");994 const entry = await vault.addEntry({995 title: "GitHub",996 username: "june",997 password: "secret123",998 url: "https://github.com",999 });10001001 const res = await dispatch(1002 {1003 type: "GET_ENTRY_FOR_FILL",1004 payload: { id: entry.id, explicitSummon: true },1005 },1006 tabSender("https://evil.example.com/phish"),1007 );1008 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1009 });10101011 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1012 const vault = await import("./vault");1013 await vault.createVault("correct-pass-12345");1014 const entry = await vault.addEntry({1015 title: "GitHub",1016 username: "june",1017 password: "secret123",1018 url: "https://github.com",1019 });10201021 const res = await dispatch(1022 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1023 tabSender("not a url"),1024 );1025 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1026 });10271028 10291030 async function seedTwoDomainVault() {1031 const vault = await import("./vault");1032 await vault.createVault("correct-pass-12345");1033 await vault.addEntry({1034 title: "GitHub",1035 username: "june",1036 password: "gh",1037 url: "https://github.com",1038 });1039 await vault.addEntry({1040 title: "Example",1041 username: "june",1042 password: "ex",1043 url: "https://example.com",1044 });1045 }10461047 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1048 await seedTwoDomainVault();10491050 const res = await dispatch({1051 type: "AUTOFILL_QUERY",1052 payload: { domain: "github.com", includeAll: true },1053 });1054 expect(res.ok).toBe(true);1055 const data = res.data as Array<{ title: string }>;1056 1057 expect(data).toHaveLength(1);1058 expect(data[0].title).toBe("GitHub");1059 });10601061 it("MV-2: AUTOFILL_QUERY with includeAll + userGesture returns the vault-wide list", async () => {1062 await seedTwoDomainVault();10631064 const res = await dispatch({1065 type: "AUTOFILL_QUERY",1066 payload: { domain: "github.com", includeAll: true, userGesture: true },1067 });1068 expect(res.ok).toBe(true);1069 const data = res.data as Array<{ title: string }>;1070 expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);1071 });10721073 10741075 async function seedPendingVouched(deviceId: string) {1076 await chrome.storage.local.set({1077 peach_pending_vouched_devices_v1: [{1078 deviceId,1079 deviceName: "New Pixel",1080 deviceType: "android",1081 longTermPublicKeyHex: "e".repeat(64),1082 signingPublicKeyHex: "a".repeat(64),1083 wsPort: 51234,1084 pairedAt: 1_700_000_000_020,1085 receivedAt: 1_700_000_000_050,1086 vouchedByDeviceId: "extension-issuer",1087 }],1088 });1089 }10901091 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1092 const vault = await import("./vault");1093 await vault.createVault("correct-pass-12345");1094 await seedPendingVouched("android-new");1095 const { findPairedDeviceById } = await import("./paired-devices");1096 const { listPendingVouchedDevices } = await import(1097 "./pending-vouched-devices"1098 );10991100 const res = await dispatch({1101 type: "CONFIRM_VOUCHED_DEVICE",1102 payload: { deviceId: "android-new" },1103 });1104 expect(res).toMatchObject({ ok: true });11051106 expect(await findPairedDeviceById("android-new")).toMatchObject({1107 deviceName: "New Pixel",1108 trustSource: "vouched",1109 longTermPublicKeyHex: "e".repeat(64),1110 });1111 expect(await listPendingVouchedDevices()).toHaveLength(0);1112 });11131114 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1115 const vault = await import("./vault");1116 await vault.createVault("correct-pass-12345");1117 await seedPendingVouched("android-relabelled");1118 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1119 await import("./paired-devices");1120 const { listPendingVouchedDevices } = await import(1121 "./pending-vouched-devices"1122 );11231124 1125 1126 await upsertPairedDevice({1127 deviceId: "android-old",1128 deviceName: "New Pixel",1129 deviceType: "android",1130 longTermPublicKeyHex: "e".repeat(64),1131 signingPublicKeyHex: "a".repeat(64),1132 pairedAt: 1_700_000_000_000,1133 trustSource: "vouched",1134 });1135 await revokeDeviceById("android-old", "self");11361137 const res = await dispatch({1138 type: "CONFIRM_VOUCHED_DEVICE",1139 payload: { deviceId: "android-relabelled" },1140 });1141 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1142 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1143 expect(await listPendingVouchedDevices()).toHaveLength(0);1144 });11451146 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1147 const vault = await import("./vault");1148 await vault.createVault("correct-pass-12345");1149 await seedPendingVouched("android-new");1150 const { findPairedDeviceById } = await import("./paired-devices");1151 const { listPendingVouchedDevices } = await import(1152 "./pending-vouched-devices"1153 );11541155 const res = await dispatch({1156 type: "REJECT_VOUCHED_DEVICE",1157 payload: { deviceId: "android-new" },1158 });1159 expect(res).toMatchObject({ ok: true });11601161 expect(await findPairedDeviceById("android-new")).toBeNull();1162 expect(await listPendingVouchedDevices()).toHaveLength(0);1163 });11641165 11661167 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1168 const vault = await import("./vault");1169 await vault.createVault("correct-pass-12345");1170 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1171 await import("./paired-devices");11721173 await upsertPairedDevice({1174 deviceId: "phone-1",1175 deviceName: "June's Phone",1176 deviceType: "android",1177 longTermPublicKeyHex: "a".repeat(64),1178 signingPublicKeyHex: "b".repeat(64),1179 pairedAt: 1_700_000_000_000,1180 trustSource: "direct",1181 });11821183 const res = await dispatch({1184 type: "UNPAIR_DEVICE",1185 payload: { deviceId: "phone-1" },1186 });1187 expect(res).toMatchObject({ ok: true });11881189 1190 expect(await findPairedDeviceById("phone-1")).toBeNull();1191 1192 1193 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1194 const records = await listDeviceRevocations();1195 expect(records).toHaveLength(1);1196 expect(records[0]).toMatchObject({1197 deviceId: "phone-1",1198 longTermPublicKeyHex: "a".repeat(64),1199 revokedByDeviceId: expect.any(String),1200 });1201 });12021203 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1204 const vault = await import("./vault");1205 await vault.createVault("correct-pass-12345");1206 const { listDeviceRevocations } = await import("./paired-devices");12071208 const res = await dispatch({1209 type: "UNPAIR_DEVICE",1210 payload: { deviceId: "never-paired" },1211 });1212 expect(res).toMatchObject({ ok: true });1213 1214 1215 1216 1217 const records = await listDeviceRevocations();1218 expect(records).toHaveLength(1);1219 expect(records[0]).toMatchObject({1220 deviceId: "never-paired",1221 longTermPublicKeyHex: "0".repeat(64),1222 });1223 });12241225 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1226 const res = await dispatch({1227 type: "UNPAIR_DEVICE",1228 payload: { deviceId: "" },1229 });1230 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1231 });12321233 1234 1235 1236 1237 1238 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1239 const vault = await import("./vault");1240 await vault.createVault("correct-pass-12345");1241 const contentScriptSender = {1242 id: "peach-extension",1243 url: "https://evil.example/page",1244 };1245 const extensionPageSender = {1246 id: "peach-extension",1247 url: "chrome-extension://peach-extension/popup.html",1248 };1249 const completion = { requestId: 1, rpId: "evil.example" };12501251 1252 await expect(1253 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1254 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });12551256 1257 1258 await expect(1259 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1260 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1261 });12621263 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1264 const vault = await import("./vault");1265 await vault.createVault("correct-pass-12345");1266 const extensionPageSender = {1267 id: "peach-extension",1268 url: "chrome-extension://peach-extension/popup.html",1269 };1270 const completion = {1271 requestId: 123,1272 rpId: "example.com",1273 rpName: "Example",1274 userName: "june",1275 userHandle: "BBBB",1276 credentialId: "cred-1",1277 publicKeyCose: "c3BraQ",1278 algorithm: -7 as const,1279 };12801281 1282 await expect(1283 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1284 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });12851286 1287 sessionStorage.passkey_pending_firefox = {1288 requestId: 123,1289 type: "create",1290 rpId: "example.com",1291 rpName: "Example",1292 userName: "june",1293 browser: "firefox",1294 challenge: "AAAA",1295 userHandle: "BBBB",1296 };12971298 1299 await expect(1300 dispatch(1301 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1302 extensionPageSender1303 )1304 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });13051306 1307 await expect(1308 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1309 ).resolves.toMatchObject({ ok: true });1310 const entries = await dispatch({ type: "GET_ENTRIES" });1311 expect(entries).toMatchObject({ ok: true });1312 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1313 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1314 });13151316 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1317 await expect(1318 dispatch(1319 {1320 type: "INITIATE_FIREFOX_PASSKEY_CREATE",1321 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1322 },1323 { id: "peach-extension", url: "https://evil.example/page" }1324 )1325 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1326 });13271328 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1329 1330 1331 1332 1333 const vault = await import("./vault");1334 await vault.createVault("correct-pass-12345");1335 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1336 "./paired-devices"1337 );13381339 await upsertPairedDevice({1340 deviceId: "phone-1",1341 deviceName: "June's Phone",1342 deviceType: "android",1343 longTermPublicKeyHex: "a".repeat(64),1344 signingPublicKeyHex: "b".repeat(64),1345 pairedAt: 1_700_000_000_000,1346 trustSource: "direct",1347 });13481349 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1350 await revokeDeviceById("phone-1", "self");1351 1352 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1353 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1354 });13551356 13571358 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1359 const vault = await import("./vault");1360 await vault.createVault("correct-pass-12345");13611362 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1363 expect(res).toMatchObject({ ok: false, error: "password_required" });1364 });13651366 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1367 const vault = await import("./vault");1368 await vault.createVault("correct-pass-12345");13691370 const res = await dispatch({1371 type: "GET_RECOVERY_WORDS",1372 payload: { password: "not-the-password" },1373 });1374 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1375 expect(res.data).toBeUndefined();1376 });13771378 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1379 const vault = await import("./vault");1380 await vault.createVault("correct-pass-12345");13811382 const res = await dispatch({1383 type: "GET_RECOVERY_WORDS",1384 payload: { password: "correct-pass-12345" },1385 });1386 expect(res.ok).toBe(true);1387 expect((res.data as string[]).length).toBe(12);1388 });13891390 it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1391 const vault = await import("./vault");1392 const password = "correct-pass-12345";1393 const popupSender = {1394 id: "peach-extension",1395 url: "chrome-extension://peach-extension/src/popup/index.html",1396 } as chrome.runtime.MessageSender;13971398 await vault.createVault(password);1399 await vault.setupPinUnlock(password, "1234");1400 await vault.lock();14011402 await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1403 ok: true,1404 data: { configured: true, available: true, attemptsRemaining: 3 },1405 });1406 });1407});