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 onMessage: {40 addListener: vi.fn((fn: MessageListener) => {41 listener = fn;42 }),43 },44 sendMessage: vi.fn(async () => ({ ok: true })),45 },46 tabs: {47 query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {48 if (typeof callback === "function") {49 callback(tabs);50 return;51 }52 return Promise.resolve(tabs);53 }),54 sendMessage: vi.fn(async () => ({ ok: true })),55 },56 storage: {57 local: {58 get: vi.fn(async (key?: string | string[]) => {59 if (!key) return storage;60 if (typeof key === "string") {61 return { [key]: storage[key] };62 }6364 return key.reduce<Record<string, unknown>>((result, entry) => {65 result[entry] = storage[entry];66 return result;67 }, {});68 }),69 set: vi.fn(async (next: Record<string, unknown>) => {70 Object.assign(storage, next);71 }),72 remove: vi.fn(async (keys: string | string[]) => {73 const list = Array.isArray(keys) ? keys : [keys];74 for (const key of list) {75 delete storage[key];76 }77 }),78 },79 session: {80 get: vi.fn(async (key?: string | string[]) => {81 if (!key) return sessionStorage;82 if (typeof key === "string") {83 return { [key]: sessionStorage[key] };84 }85 return key.reduce<Record<string, unknown>>((result, entry) => {86 result[entry] = sessionStorage[entry];87 return result;88 }, {});89 }),90 set: vi.fn(async (next: Record<string, unknown>) => {91 Object.assign(sessionStorage, next);92 }),93 remove: vi.fn(async (keys: string | string[]) => {94 const list = Array.isArray(keys) ? keys : [keys];95 for (const key of list) {96 delete sessionStorage[key];97 }98 }),99 clear: vi.fn(async () => {100 for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];101 }),102 },103 },104 alarms: {105 clear: vi.fn(),106 create: vi.fn(),107 onAlarm: { addListener: vi.fn() },108 },109 idle: {110 setDetectionInterval: vi.fn(),111 onStateChanged: { addListener: vi.fn() },112 },113 scripting: {114 executeScript: vi.fn(async () => []),115 },116 } as unknown as typeof chrome;117118 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;119120 const messages = await import("./messages");121 messages.setupMessageHandler();122 const vault = await import("./vault");123 await vault.ensureSettingsLoaded();124 });125126 async function dispatch(127 message: Message,128 sender: chrome.runtime.MessageSender = { id: "peach-extension" }129 ): Promise<MessageResponse> {130 return await new Promise<MessageResponse>((resolve) => {131 const keepChannelOpen = listener(message, sender, (response) => resolve(response));132 expect(keepChannelOpen).toBe(true);133 });134 }135136 it("GET_ENTRIES when unlocked returns the entry array", async () => {137 const vault = await import("./vault");138 await vault.createVault("correct-pass-12345");139 await vault.addEntry({140 title: "GitHub",141 username: "june",142 password: "secret123",143 url: "https://github.com",144 });145146 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({147 ok: true,148 data: [149 expect.objectContaining({150 title: "GitHub",151 username: "june",152 }),153 ],154 });155 });156157 it("GET_ENTRIES when locked returns a vault locked error", async () => {158 const vault = await import("./vault");159 await vault.createVault("correct-pass-12345");160 vault.lock();161162 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({163 ok: false,164 error: "vault_locked",165 });166 });167168 it("ADD_ENTRY with a valid payload returns the created entry", async () => {169 const vault = await import("./vault");170 await vault.createVault("correct-pass-12345");171172 const response = await dispatch({173 type: "ADD_ENTRY",174 payload: {175 title: "GitHub",176 username: "june",177 password: "secret123",178 url: "https://github.com",179 },180 });181182 expect(response).toMatchObject({183 ok: true,184 data: expect.objectContaining({185 id: expect.any(String),186 title: "GitHub",187 }),188 });189 expect(vault.getEntries()).toHaveLength(1);190 });191192 it("ADD_ENTRY when locked returns a vault locked error", async () => {193 const vault = await import("./vault");194 await vault.createVault("correct-pass-12345");195 vault.lock();196197 await expect(198 dispatch({199 type: "ADD_ENTRY",200 payload: {201 title: "GitHub",202 username: "june",203 password: "secret123",204 url: "https://github.com",205 },206 })207 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });208 });209210 it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {211 const vault = await import("./vault");212 await vault.createVault("correct-pass-12345");213214 await expect(215 dispatch({216 type: "ADD_ENTRY",217 payload: {218 title: "GitHub",219 username: "june",220 password: "",221 url: "https://github.com",222 },223 })224 ).resolves.toMatchObject({ ok: true });225 });226227 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {228 const vault = await import("./vault");229 await vault.createVault("correct-pass-12345");230 const entry = await vault.addEntry({231 title: "GitHub",232 username: "june",233 password: "secret123",234 url: "https://github.com",235 });236237 await expect(238 dispatch({239 type: "UPDATE_ENTRY",240 payload: { id: entry.id, patch: { password: "new-secret" } },241 })242 ).resolves.toMatchObject({ ok: true });243244 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");245 });246247 it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {248 const vault = await import("./vault");249 await vault.createVault("correct-pass-12345");250 const entry = await vault.addEntry({251 title: "GitHub",252 username: "june",253 password: "secret123",254 url: "https://github.com",255 });256257 await expect(258 dispatch({259 type: "UPDATE_ENTRY",260 payload: { id: entry.id, patch: { password: "" } },261 })262 ).resolves.toMatchObject({ ok: true });263264 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();265 });266267 it("DELETE_ENTRY soft-deletes the target entry", async () => {268 const vault = await import("./vault");269 await vault.createVault("correct-pass-12345");270 const entry = await vault.addEntry({271 title: "GitHub",272 username: "june",273 password: "secret123",274 url: "https://github.com",275 });276277 await expect(278 dispatch({279 type: "DELETE_ENTRY",280 payload: { id: entry.id },281 })282 ).resolves.toMatchObject({ ok: true });283284 expect(vault.getEntries()).toEqual([]);285 expect(vault.getDeletedEntries()).toHaveLength(1);286 });287288 it("DELETE_ENTRY of a missing id returns an error", async () => {289 const vault = await import("./vault");290 await vault.createVault("correct-pass-12345");291292 await expect(293 dispatch({294 type: "DELETE_ENTRY",295 payload: { id: "missing" },296 })297 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });298 });299300 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {301 const vault = await import("./vault");302 await vault.createVault("correct-pass-12345");303 const entry = await vault.addEntry({304 title: "GitHub",305 username: "june",306 password: "secret123",307 url: "https://github.com",308 });309310 const response = await dispatch({311 type: "GET_ENTRY_FOR_FILL",312 payload: { id: entry.id },313 });314315 expect(response).toMatchObject({316 ok: true,317 data: expect.objectContaining({318 id: entry.id,319 username: "june",320 password: "secret123",321 }),322 });323 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();324 });325326 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {327 const vault = await import("./vault");328 await vault.createVault("correct-pass-12345");329 vault.lock();330331 await expect(332 dispatch({333 type: "GET_ENTRY_FOR_FILL",334 payload: { id: "missing" },335 })336 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });337 });338339 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {340 const vault = await import("./vault");341 await vault.createVault("correct-pass-12345");342 const generated = await generatePasskeyKeypair(-7);343 const entry = await vault.addEntry({344 type: "login",345 title: "WebAuthn.io",346 passkey: {347 rpId: "webauthn.io",348 rpName: "WebAuthn.io",349 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),350 publicKeyCose: base64urlEncode(generated.publicKeyCose),351 privateKeyJwk: generated.privateKeyJwk,352 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),353 userName: "june",354 userDisplayName: "June",355 algorithm: -7,356 signCount: 0,357 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",358 backupEligible: true,359 backupState: false,360 createdAt: Date.now(),361 },362 });363364 await expect(365 dispatch({366 type: "GET_ENTRY_FOR_FILL",367 payload: { id: entry.id },368 })369 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });370 });371372 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {373 const vault = await import("./vault");374 await vault.createVault("correct-pass-12345");375 await vault.addEntry({376 title: "GitHub",377 username: "june",378 password: "secret123",379 url: "https://github.com",380 totp: "JBSWY3DPEHPK3PXP",381 });382383 const response = await dispatch({384 type: "AUTOFILL_QUERY",385 payload: { domain: "github.com" },386 });387388 expect(response).toMatchObject({389 ok: true,390 data: [391 {392 id: expect.any(String),393 title: "GitHub",394 username: "june",395 url: "https://github.com",396 hasTotp: true,397 },398 ],399 });400 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();401 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();402 });403404 it("AUTOFILL_QUERY includeAll returns only id and title for non-note, non-passkey entries", async () => {405 const vault = await import("./vault");406 await vault.createVault("correct-pass-12345");407 await vault.addEntry({408 title: "GitHub",409 username: "june",410 password: "secret123",411 url: "https://github.com",412 });413 await vault.addEntry({414 type: "note",415 title: "Secure note",416 notes: "hello",417 });418419 const response = await dispatch({420 type: "AUTOFILL_QUERY",421 payload: { domain: "github.com", includeAll: true },422 });423424 expect(response).toMatchObject({425 ok: true,426 data: [{ id: expect.any(String), title: "GitHub" }],427 });428 expect(response.data).toHaveLength(1);429 });430431 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {432 const vault = await import("./vault");433 await vault.createVault("correct-pass-12345");434 await vault.addEntry({435 title: "GitHub",436 username: "june",437 password: "secret123",438 url: "https://github.com",439 totp: "JBSWY3DPEHPK3PXP",440 });441 await vault.addEntry({442 title: "No 2FA",443 username: "person",444 password: "secret123",445 url: "https://github.com",446 });447448 const response = await dispatch({449 type: "AUTOFILL_QUERY",450 payload: { domain: "github.com", kind: "totp" },451 });452453 expect(response).toMatchObject({454 ok: true,455 data: [456 {457 id: expect.any(String),458 title: "GitHub",459 username: "june",460 url: "https://github.com",461 hasTotp: true,462 },463 ],464 });465 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();466 expect(response.data).toHaveLength(1);467 });468469 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {470 const vault = await import("./vault");471 await vault.createVault("correct-pass-12345");472 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });473474 const response = await dispatch({475 type: "RESTORE_ENTRIES",476 payload: {477 mode: "replace",478 entries: [479 {480 id: "restored-1",481 type: "login",482 title: "B",483 username: "june",484 password: "two",485 url: "https://b.test",486 createdAt: 1,487 modifiedAt: 1,488 },489 ],490 },491 });492493 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });494 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);495 });496497 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {498 const vault = await import("./vault");499 await vault.createVault("correct-pass-12345");500501 await expect(502 dispatch({503 type: "RESTORE_ENTRIES",504 payload: { mode: "replace", entries: [] },505 })506 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });507 });508509 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {510 const vault = await import("./vault");511 await vault.createVault("correct-pass-12345");512513 await expect(514 dispatch({515 type: "CHANGE_PASSWORD",516 payload: {517 currentPassword: "correct-pass-12345",518 newPassword: "new-pass-67890",519 },520 })521 ).resolves.toMatchObject({ ok: true });522523 vault.lock();524 expect(await vault.unlock("new-pass-67890")).toBe(true);525526 await expect(527 dispatch({528 type: "CHANGE_PASSWORD",529 payload: {530 currentPassword: "wrong-pass",531 newPassword: "another-pass",532 },533 })534 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });535 });536537 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {538 const vault = await import("./vault");539 await vault.createVault("correct-pass-12345");540541 (542 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>543 ).mockClear();544 (545 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>546 ).mockClear();547548 await expect(549 dispatch({550 type: "UPDATE_SETTINGS",551 payload: { autoLockMinutes: 30 },552 })553 ).resolves.toMatchObject({ ok: true });554555 expect(vault.getSettings().autoLockMinutes).toBe(30);556 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);557 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {558 delayInMinutes: 30,559 });560 });561562 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {563 vi.useFakeTimers();564 const vault = await import("./vault");565 await vault.createVault("correct-pass-12345");566 const entry = await vault.addEntry({567 title: "GitHub",568 username: "june",569 password: "secret123",570 url: "https://github.com",571 });572 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);573574 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });575 await vi.runAllTimersAsync();576 const response = await responsePromise;577578 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });579 expect(vault.getEntryById(entry.id)?.breach).toBe(7);580 breachSpy.mockRestore();581 });582583 it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {584 const vault = await import("./vault");585 await vault.createVault("correct-pass-12345");586587 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({588 ok: false,589 error: "invalid_sender",590 });591592 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({593 ok: true,594 data: [],595 });596 });597598 it("LOCK and UNLOCK messages update vault state correctly", async () => {599 const vault = await import("./vault");600601 await expect(602 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })603 ).resolves.toMatchObject({ ok: true });604 expect(vault.isUnlocked()).toBe(true);605606 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });607 expect(vault.isUnlocked()).toBe(false);608609 await expect(610 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })611 ).resolves.toMatchObject({ ok: false });612 expect(vault.isUnlocked()).toBe(false);613614 await expect(615 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })616 ).resolves.toMatchObject({ ok: true });617 expect(vault.isUnlocked()).toBe(true);618 });619620 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {621 const response = await dispatch({622 type: "FIRE_CANARY_PING",623 payload: {624 webhookUrl: "https://example.com/hook",625 entryTitle: "GitHub",626 trigger: "password_copied",627 },628 });629630 expect(response).toMatchObject({ ok: true });631 expect(fetchMock).toHaveBeenCalledWith(632 "https://example.com/hook",633 expect.objectContaining({634 method: "POST",635 headers: { "Content-Type": "application/json" },636 body: expect.any(String),637 })638 );639 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;640 expect(body).toMatchObject({641 entry: "GitHub",642 trigger: "password_copied",643 source: "peach-extension",644 });645 });646647 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {648 await expect(649 dispatch({650 type: "FIRE_CANARY_PING",651 payload: {652 webhookUrl: "http://example.com/hook",653 entryTitle: "GitHub",654 trigger: "password_copied",655 },656 })657 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });658659 expect(fetchMock).not.toHaveBeenCalled();660 });661662 it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {663 const response = await dispatch({664 type: "SUBMIT_FEEDBACK",665 payload: {666 category: "Bug report",667 message: "the popup flashes",668 version: "2.1.0",669 browser: "Chrome",670 },671 });672673 expect(response).toMatchObject({ ok: true });674 expect(fetchMock).not.toHaveBeenCalled();675 });676677 it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {678 const response = await dispatch({679 type: "SUBMIT_FEEDBACK",680 payload: {681 category: "Bug report",682 message: " ",683 version: "2.1.0",684 browser: "Chrome",685 },686 });687688 expect(response).toMatchObject({ ok: false, error: "message_empty" });689 expect(fetchMock).not.toHaveBeenCalled();690 });691692 it("GET_IDENTITIES when locked returns vault locked error", async () => {693 const vault = await import("./vault");694 await vault.createVault("correct-pass-12345");695 vault.lock();696 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({697 ok: false,698 error: "vault_locked",699 });700 });701702 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {703 const vault = await import("./vault");704 await vault.createVault("correct-pass-12345");705 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });706 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });707 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });708709 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };710 expect(resp.ok).toBe(true);711 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;712 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({713 type: "email",714 useCount: 2,715 });716 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({717 type: "username",718 useCount: 1,719 });720 721 const emailIdx = data.findIndex((d) => d.value === "[email protected]");722 const userIdx = data.findIndex((d) => d.value === "juni_patel");723 expect(emailIdx).toBeLessThan(userIdx);724 });725726 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {727 const vault = await import("./vault");728 await vault.createVault("correct-pass-12345");729 for (let i = 0; i < 3; i++) {730 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });731 }732733 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };734 const data = resp.data as Array<{ value: string; useCount: number }>;735 const hits = data.filter((d) => d.value === "[email protected]");736 expect(hits).toHaveLength(1);737 expect(hits[0]!.useCount).toBe(3);738 });739740 it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {741 const vault = await import("./vault");742 await vault.createVault("correct-pass-12345");743 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;744745 746 alarmsCreate.mockClear();747 await dispatch({ type: "GET_ENTRIES" });748 expect(alarmsCreate).toHaveBeenCalled();749750 751 alarmsCreate.mockClear();752 await dispatch({753 type: "SUBMIT_FEEDBACK",754 payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },755 });756 expect(alarmsCreate).not.toHaveBeenCalled();757 });758});