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 a missing password returns a validation error", 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: false, error: "password or passkey required" });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 login password returns an error", 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: false, error: "password or passkey required" });263 });264265 it("DELETE_ENTRY soft-deletes the target entry", async () => {266 const vault = await import("./vault");267 await vault.createVault("correct-pass-12345");268 const entry = await vault.addEntry({269 title: "GitHub",270 username: "june",271 password: "secret123",272 url: "https://github.com",273 });274275 await expect(276 dispatch({277 type: "DELETE_ENTRY",278 payload: { id: entry.id },279 })280 ).resolves.toMatchObject({ ok: true });281282 expect(vault.getEntries()).toEqual([]);283 expect(vault.getDeletedEntries()).toHaveLength(1);284 });285286 it("DELETE_ENTRY of a missing id returns an error", async () => {287 const vault = await import("./vault");288 await vault.createVault("correct-pass-12345");289290 await expect(291 dispatch({292 type: "DELETE_ENTRY",293 payload: { id: "missing" },294 })295 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });296 });297298 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {299 const vault = await import("./vault");300 await vault.createVault("correct-pass-12345");301 const entry = await vault.addEntry({302 title: "GitHub",303 username: "june",304 password: "secret123",305 url: "https://github.com",306 });307308 const response = await dispatch({309 type: "GET_ENTRY_FOR_FILL",310 payload: { id: entry.id },311 });312313 expect(response).toMatchObject({314 ok: true,315 data: expect.objectContaining({316 id: entry.id,317 username: "june",318 password: "secret123",319 }),320 });321 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();322 });323324 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {325 const vault = await import("./vault");326 await vault.createVault("correct-pass-12345");327 vault.lock();328329 await expect(330 dispatch({331 type: "GET_ENTRY_FOR_FILL",332 payload: { id: "missing" },333 })334 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });335 });336337 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {338 const vault = await import("./vault");339 await vault.createVault("correct-pass-12345");340 const generated = await generatePasskeyKeypair(-7);341 const entry = await vault.addEntry({342 type: "login",343 title: "WebAuthn.io",344 passkey: {345 rpId: "webauthn.io",346 rpName: "WebAuthn.io",347 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),348 publicKeyCose: base64urlEncode(generated.publicKeyCose),349 privateKeyJwk: generated.privateKeyJwk,350 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),351 userName: "june",352 userDisplayName: "June",353 algorithm: -7,354 signCount: 0,355 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",356 backupEligible: true,357 backupState: false,358 createdAt: Date.now(),359 },360 });361362 await expect(363 dispatch({364 type: "GET_ENTRY_FOR_FILL",365 payload: { id: entry.id },366 })367 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });368 });369370 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {371 const vault = await import("./vault");372 await vault.createVault("correct-pass-12345");373 await vault.addEntry({374 title: "GitHub",375 username: "june",376 password: "secret123",377 url: "https://github.com",378 totp: "JBSWY3DPEHPK3PXP",379 });380381 const response = await dispatch({382 type: "AUTOFILL_QUERY",383 payload: { domain: "github.com" },384 });385386 expect(response).toMatchObject({387 ok: true,388 data: [389 {390 id: expect.any(String),391 title: "GitHub",392 username: "june",393 url: "https://github.com",394 hasTotp: true,395 },396 ],397 });398 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();399 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();400 });401402 it("AUTOFILL_QUERY includeAll returns only id and title for non-note, non-passkey entries", async () => {403 const vault = await import("./vault");404 await vault.createVault("correct-pass-12345");405 await vault.addEntry({406 title: "GitHub",407 username: "june",408 password: "secret123",409 url: "https://github.com",410 });411 await vault.addEntry({412 type: "note",413 title: "Secure note",414 notes: "hello",415 });416417 const response = await dispatch({418 type: "AUTOFILL_QUERY",419 payload: { domain: "github.com", includeAll: true },420 });421422 expect(response).toMatchObject({423 ok: true,424 data: [{ id: expect.any(String), title: "GitHub" }],425 });426 expect(response.data).toHaveLength(1);427 });428429 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {430 const vault = await import("./vault");431 await vault.createVault("correct-pass-12345");432 await vault.addEntry({433 title: "GitHub",434 username: "june",435 password: "secret123",436 url: "https://github.com",437 totp: "JBSWY3DPEHPK3PXP",438 });439 await vault.addEntry({440 title: "No 2FA",441 username: "person",442 password: "secret123",443 url: "https://github.com",444 });445446 const response = await dispatch({447 type: "AUTOFILL_QUERY",448 payload: { domain: "github.com", kind: "totp" },449 });450451 expect(response).toMatchObject({452 ok: true,453 data: [454 {455 id: expect.any(String),456 title: "GitHub",457 username: "june",458 url: "https://github.com",459 hasTotp: true,460 },461 ],462 });463 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();464 expect(response.data).toHaveLength(1);465 });466467 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {468 const vault = await import("./vault");469 await vault.createVault("correct-pass-12345");470 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });471472 const response = await dispatch({473 type: "RESTORE_ENTRIES",474 payload: {475 mode: "replace",476 entries: [477 {478 id: "restored-1",479 type: "login",480 title: "B",481 username: "june",482 password: "two",483 url: "https://b.test",484 createdAt: 1,485 modifiedAt: 1,486 },487 ],488 },489 });490491 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });492 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);493 });494495 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {496 const vault = await import("./vault");497 await vault.createVault("correct-pass-12345");498499 await expect(500 dispatch({501 type: "RESTORE_ENTRIES",502 payload: { mode: "replace", entries: [] },503 })504 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });505 });506507 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {508 const vault = await import("./vault");509 await vault.createVault("correct-pass-12345");510511 await expect(512 dispatch({513 type: "CHANGE_PASSWORD",514 payload: {515 currentPassword: "correct-pass-12345",516 newPassword: "new-pass-67890",517 },518 })519 ).resolves.toMatchObject({ ok: true });520521 vault.lock();522 expect(await vault.unlock("new-pass-67890")).toBe(true);523524 await expect(525 dispatch({526 type: "CHANGE_PASSWORD",527 payload: {528 currentPassword: "wrong-pass",529 newPassword: "another-pass",530 },531 })532 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });533 });534535 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {536 const vault = await import("./vault");537 await vault.createVault("correct-pass-12345");538539 (540 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>541 ).mockClear();542 (543 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>544 ).mockClear();545546 await expect(547 dispatch({548 type: "UPDATE_SETTINGS",549 payload: { autoLockMinutes: 30 },550 })551 ).resolves.toMatchObject({ ok: true });552553 expect(vault.getSettings().autoLockMinutes).toBe(30);554 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);555 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {556 delayInMinutes: 30,557 });558 });559560 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {561 vi.useFakeTimers();562 const vault = await import("./vault");563 await vault.createVault("correct-pass-12345");564 const entry = await vault.addEntry({565 title: "GitHub",566 username: "june",567 password: "secret123",568 url: "https://github.com",569 });570 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);571572 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });573 await vi.runAllTimersAsync();574 const response = await responsePromise;575576 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });577 expect(vault.getEntryById(entry.id)?.breach).toBe(7);578 breachSpy.mockRestore();579 });580581 it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {582 const vault = await import("./vault");583 await vault.createVault("correct-pass-12345");584585 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({586 ok: false,587 error: "invalid_sender",588 });589590 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({591 ok: true,592 data: [],593 });594 });595596 it("LOCK and UNLOCK messages update vault state correctly", async () => {597 const vault = await import("./vault");598599 await expect(600 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })601 ).resolves.toMatchObject({ ok: true });602 expect(vault.isUnlocked()).toBe(true);603604 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });605 expect(vault.isUnlocked()).toBe(false);606607 await expect(608 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })609 ).resolves.toMatchObject({ ok: false });610 expect(vault.isUnlocked()).toBe(false);611612 await expect(613 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })614 ).resolves.toMatchObject({ ok: true });615 expect(vault.isUnlocked()).toBe(true);616 });617618 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {619 const response = await dispatch({620 type: "FIRE_CANARY_PING",621 payload: {622 webhookUrl: "https://example.com/hook",623 entryTitle: "GitHub",624 trigger: "password_copied",625 },626 });627628 expect(response).toMatchObject({ ok: true });629 expect(fetchMock).toHaveBeenCalledWith(630 "https://example.com/hook",631 expect.objectContaining({632 method: "POST",633 headers: { "Content-Type": "application/json" },634 body: expect.any(String),635 })636 );637 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;638 expect(body).toMatchObject({639 entry: "GitHub",640 trigger: "password_copied",641 source: "peach-extension",642 });643 });644645 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {646 await expect(647 dispatch({648 type: "FIRE_CANARY_PING",649 payload: {650 webhookUrl: "http://example.com/hook",651 entryTitle: "GitHub",652 trigger: "password_copied",653 },654 })655 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });656657 expect(fetchMock).not.toHaveBeenCalled();658 });659660 it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {661 const response = await dispatch({662 type: "SUBMIT_FEEDBACK",663 payload: {664 category: "Bug report",665 message: "the popup flashes",666 version: "2.1.0",667 browser: "Chrome",668 },669 });670671 expect(response).toMatchObject({ ok: true });672 expect(fetchMock).not.toHaveBeenCalled();673 });674675 it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {676 const response = await dispatch({677 type: "SUBMIT_FEEDBACK",678 payload: {679 category: "Bug report",680 message: " ",681 version: "2.1.0",682 browser: "Chrome",683 },684 });685686 expect(response).toMatchObject({ ok: false, error: "message_empty" });687 expect(fetchMock).not.toHaveBeenCalled();688 });689690 it("GET_IDENTITIES when locked returns vault locked error", async () => {691 const vault = await import("./vault");692 await vault.createVault("correct-pass-12345");693 vault.lock();694 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({695 ok: false,696 error: "vault_locked",697 });698 });699700 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {701 const vault = await import("./vault");702 await vault.createVault("correct-pass-12345");703 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });704 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });705 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });706707 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };708 expect(resp.ok).toBe(true);709 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;710 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({711 type: "email",712 useCount: 2,713 });714 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({715 type: "username",716 useCount: 1,717 });718 719 const emailIdx = data.findIndex((d) => d.value === "[email protected]");720 const userIdx = data.findIndex((d) => d.value === "juni_patel");721 expect(emailIdx).toBeLessThan(userIdx);722 });723724 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {725 const vault = await import("./vault");726 await vault.createVault("correct-pass-12345");727 for (let i = 0; i < 3; i++) {728 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });729 }730731 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };732 const data = resp.data as Array<{ value: string; useCount: number }>;733 const hits = data.filter((d) => d.value === "[email protected]");734 expect(hits).toHaveLength(1);735 expect(hits[0]!.useCount).toBe(3);736 });737738 it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {739 const vault = await import("./vault");740 await vault.createVault("correct-pass-12345");741 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;742743 744 alarmsCreate.mockClear();745 await dispatch({ type: "GET_ENTRIES" });746 expect(alarmsCreate).toHaveBeenCalled();747748 749 alarmsCreate.mockClear();750 await dispatch({751 type: "SUBMIT_FEEDBACK",752 payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },753 });754 expect(alarmsCreate).not.toHaveBeenCalled();755 });756});