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 search-ready summaries 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: [427 {428 id: expect.any(String),429 title: "GitHub",430 username: "june",431 url: "https://github.com",432 hasPasskey: false,433 },434 ],435 });436 const data = response.data as Array<Record<string, unknown>>;437 expect(data).toHaveLength(1);438 439 expect(data[0]).not.toHaveProperty("password");440 expect(data[0]).not.toHaveProperty("totp");441 });442443 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {444 const vault = await import("./vault");445 await vault.createVault("correct-pass-12345");446 await vault.addEntry({447 title: "GitHub",448 username: "june",449 password: "secret123",450 url: "https://github.com",451 totp: "JBSWY3DPEHPK3PXP",452 });453 await vault.addEntry({454 title: "No 2FA",455 username: "person",456 password: "secret123",457 url: "https://github.com",458 });459460 const response = await dispatch({461 type: "AUTOFILL_QUERY",462 payload: { domain: "github.com", kind: "totp" },463 });464465 expect(response).toMatchObject({466 ok: true,467 data: [468 {469 id: expect.any(String),470 title: "GitHub",471 username: "june",472 url: "https://github.com",473 hasTotp: true,474 },475 ],476 });477 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();478 expect(response.data).toHaveLength(1);479 });480481 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {482 const vault = await import("./vault");483 await vault.createVault("correct-pass-12345");484 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });485486 const response = await dispatch({487 type: "RESTORE_ENTRIES",488 payload: {489 mode: "replace",490 entries: [491 {492 id: "restored-1",493 type: "login",494 title: "B",495 username: "june",496 password: "two",497 url: "https://b.test",498 createdAt: 1,499 modifiedAt: 1,500 },501 ],502 },503 });504505 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });506 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);507 });508509 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {510 const vault = await import("./vault");511 await vault.createVault("correct-pass-12345");512513 await expect(514 dispatch({515 type: "RESTORE_ENTRIES",516 payload: { mode: "replace", entries: [] },517 })518 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });519 });520521 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {522 const vault = await import("./vault");523 await vault.createVault("correct-pass-12345");524525 await expect(526 dispatch({527 type: "CHANGE_PASSWORD",528 payload: {529 currentPassword: "correct-pass-12345",530 newPassword: "new-pass-67890",531 },532 })533 ).resolves.toMatchObject({ ok: true });534535 vault.lock();536 expect(await vault.unlock("new-pass-67890")).toBe(true);537538 await expect(539 dispatch({540 type: "CHANGE_PASSWORD",541 payload: {542 currentPassword: "wrong-pass",543 newPassword: "another-pass",544 },545 })546 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });547 });548549 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {550 const vault = await import("./vault");551 await vault.createVault("correct-pass-12345");552553 (554 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>555 ).mockClear();556 (557 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>558 ).mockClear();559560 await expect(561 dispatch({562 type: "UPDATE_SETTINGS",563 payload: { autoLockMinutes: 30 },564 })565 ).resolves.toMatchObject({ ok: true });566567 expect(vault.getSettings().autoLockMinutes).toBe(30);568 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);569 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {570 delayInMinutes: 30,571 });572 });573574 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {575 vi.useFakeTimers();576 const vault = await import("./vault");577 await vault.createVault("correct-pass-12345");578 const entry = await vault.addEntry({579 title: "GitHub",580 username: "june",581 password: "secret123",582 url: "https://github.com",583 });584 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);585586 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });587 await vi.runAllTimersAsync();588 const response = await responsePromise;589590 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });591 expect(vault.getEntryById(entry.id)?.breach).toBe(7);592 breachSpy.mockRestore();593 });594595 it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {596 const vault = await import("./vault");597 await vault.createVault("correct-pass-12345");598599 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({600 ok: false,601 error: "invalid_sender",602 });603604 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({605 ok: true,606 data: [],607 });608 });609610 it("LOCK and UNLOCK messages update vault state correctly", async () => {611 const vault = await import("./vault");612613 await expect(614 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })615 ).resolves.toMatchObject({ ok: true });616 expect(vault.isUnlocked()).toBe(true);617618 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });619 expect(vault.isUnlocked()).toBe(false);620621 await expect(622 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })623 ).resolves.toMatchObject({ ok: false });624 expect(vault.isUnlocked()).toBe(false);625626 await expect(627 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })628 ).resolves.toMatchObject({ ok: true });629 expect(vault.isUnlocked()).toBe(true);630 });631632 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {633 const response = await dispatch({634 type: "FIRE_CANARY_PING",635 payload: {636 webhookUrl: "https://example.com/hook",637 entryTitle: "GitHub",638 trigger: "password_copied",639 },640 });641642 expect(response).toMatchObject({ ok: true });643 expect(fetchMock).toHaveBeenCalledWith(644 "https://example.com/hook",645 expect.objectContaining({646 method: "POST",647 headers: { "Content-Type": "application/json" },648 body: expect.any(String),649 })650 );651 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;652 expect(body).toMatchObject({653 entry: "GitHub",654 trigger: "password_copied",655 source: "peach-extension",656 });657 });658659 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {660 await expect(661 dispatch({662 type: "FIRE_CANARY_PING",663 payload: {664 webhookUrl: "http://example.com/hook",665 entryTitle: "GitHub",666 trigger: "password_copied",667 },668 })669 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });670671 expect(fetchMock).not.toHaveBeenCalled();672 });673674 it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {675 const response = await dispatch({676 type: "SUBMIT_FEEDBACK",677 payload: {678 category: "Bug report",679 message: "the popup flashes",680 version: "2.1.0",681 browser: "Chrome",682 },683 });684685 expect(response).toMatchObject({ ok: true });686 expect(fetchMock).not.toHaveBeenCalled();687 });688689 it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {690 const response = await dispatch({691 type: "SUBMIT_FEEDBACK",692 payload: {693 category: "Bug report",694 message: " ",695 version: "2.1.0",696 browser: "Chrome",697 },698 });699700 expect(response).toMatchObject({ ok: false, error: "message_empty" });701 expect(fetchMock).not.toHaveBeenCalled();702 });703704 it("GET_IDENTITIES when locked returns vault locked error", async () => {705 const vault = await import("./vault");706 await vault.createVault("correct-pass-12345");707 vault.lock();708 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({709 ok: false,710 error: "vault_locked",711 });712 });713714 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {715 const vault = await import("./vault");716 await vault.createVault("correct-pass-12345");717 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });718 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });719 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });720721 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };722 expect(resp.ok).toBe(true);723 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;724 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({725 type: "email",726 useCount: 2,727 });728 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({729 type: "username",730 useCount: 1,731 });732 733 const emailIdx = data.findIndex((d) => d.value === "[email protected]");734 const userIdx = data.findIndex((d) => d.value === "juni_patel");735 expect(emailIdx).toBeLessThan(userIdx);736 });737738 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {739 const vault = await import("./vault");740 await vault.createVault("correct-pass-12345");741 for (let i = 0; i < 3; i++) {742 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });743 }744745 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };746 const data = resp.data as Array<{ value: string; useCount: number }>;747 const hits = data.filter((d) => d.value === "[email protected]");748 expect(hits).toHaveLength(1);749 expect(hits[0]!.useCount).toBe(3);750 });751752 it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {753 const vault = await import("./vault");754 await vault.createVault("correct-pass-12345");755 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;756757 758 alarmsCreate.mockClear();759 await dispatch({ type: "GET_ENTRIES" });760 expect(alarmsCreate).toHaveBeenCalled();761762 763 alarmsCreate.mockClear();764 await dispatch({765 type: "SUBMIT_FEEDBACK",766 payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },767 });768 expect(alarmsCreate).not.toHaveBeenCalled();769 });770});