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 listener: MessageListener;16 let fetchMock: ReturnType<typeof vi.fn>;1718 beforeEach(async () => {19 vi.resetModules();20 vi.useRealTimers();21 storage = {22 peach_settings: { breachMonitoring: false },23 };2425 fetchMock = vi.fn(async () => ({26 ok: true,27 status: 200,28 text: async () => "",29 }));30 vi.stubGlobal("fetch", fetchMock);31 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);3233 const tabs = [{ id: 1 }];34 const chromeMock = {35 runtime: {36 id: "peach-extension",37 onMessage: {38 addListener: vi.fn((fn: MessageListener) => {39 listener = fn;40 }),41 },42 sendMessage: vi.fn(async () => ({ ok: true })),43 },44 tabs: {45 query: vi.fn((query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {46 if (typeof callback === "function") {47 callback(tabs);48 return;49 }50 return Promise.resolve(tabs);51 }),52 sendMessage: vi.fn(async () => ({ ok: true })),53 },54 storage: {55 local: {56 get: vi.fn(async (key?: string | string[]) => {57 if (!key) return storage;58 if (typeof key === "string") {59 return { [key]: storage[key] };60 }6162 return key.reduce<Record<string, unknown>>((result, entry) => {63 result[entry] = storage[entry];64 return result;65 }, {});66 }),67 set: vi.fn(async (next: Record<string, unknown>) => {68 Object.assign(storage, next);69 }),70 remove: vi.fn(async (keys: string | string[]) => {71 const list = Array.isArray(keys) ? keys : [keys];72 for (const key of list) {73 delete storage[key];74 }75 }),76 },77 },78 alarms: {79 clear: vi.fn(),80 create: vi.fn(),81 onAlarm: { addListener: vi.fn() },82 },83 idle: {84 setDetectionInterval: vi.fn(),85 onStateChanged: { addListener: vi.fn() },86 },87 scripting: {88 executeScript: vi.fn(async () => []),89 },90 } as unknown as typeof chrome;9192 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;9394 const messages = await import("./messages");95 messages.setupMessageHandler();96 const vault = await import("./vault");97 await vault.ensureSettingsLoaded();98 });99100 async function dispatch(101 message: Message,102 sender: chrome.runtime.MessageSender = { id: "peach-extension" }103 ): Promise<MessageResponse> {104 return await new Promise<MessageResponse>((resolve) => {105 const keepChannelOpen = listener(message, sender, (response) => resolve(response));106 expect(keepChannelOpen).toBe(true);107 });108 }109110 it("GET_ENTRIES when unlocked returns the entry array", async () => {111 const vault = await import("./vault");112 await vault.createVault("correct-pass-12345");113 await vault.addEntry({114 title: "GitHub",115 username: "june",116 password: "secret123",117 url: "https://github.com",118 });119120 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({121 ok: true,122 data: [123 expect.objectContaining({124 title: "GitHub",125 username: "june",126 }),127 ],128 });129 });130131 it("GET_ENTRIES when locked returns a vault locked error", async () => {132 const vault = await import("./vault");133 await vault.createVault("correct-pass-12345");134 vault.lock();135136 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({137 ok: false,138 error: "vault locked",139 });140 });141142 it("ADD_ENTRY with a valid payload returns the created entry", async () => {143 const vault = await import("./vault");144 await vault.createVault("correct-pass-12345");145146 const response = await dispatch({147 type: "ADD_ENTRY",148 payload: {149 title: "GitHub",150 username: "june",151 password: "secret123",152 url: "https://github.com",153 },154 });155156 expect(response).toMatchObject({157 ok: true,158 data: expect.objectContaining({159 id: expect.any(String),160 title: "GitHub",161 }),162 });163 expect(vault.getEntries()).toHaveLength(1);164 });165166 it("ADD_ENTRY when locked returns a vault locked error", async () => {167 const vault = await import("./vault");168 await vault.createVault("correct-pass-12345");169 vault.lock();170171 await expect(172 dispatch({173 type: "ADD_ENTRY",174 payload: {175 title: "GitHub",176 username: "june",177 password: "secret123",178 url: "https://github.com",179 },180 })181 ).resolves.toMatchObject({ ok: false, error: "vault locked" });182 });183184 it("ADD_ENTRY with a missing password returns a validation error", async () => {185 const vault = await import("./vault");186 await vault.createVault("correct-pass-12345");187188 await expect(189 dispatch({190 type: "ADD_ENTRY",191 payload: {192 title: "GitHub",193 username: "june",194 password: "",195 url: "https://github.com",196 },197 })198 ).resolves.toMatchObject({ ok: false, error: "password or passkey required" });199 });200201 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {202 const vault = await import("./vault");203 await vault.createVault("correct-pass-12345");204 const entry = await vault.addEntry({205 title: "GitHub",206 username: "june",207 password: "secret123",208 url: "https://github.com",209 });210211 await expect(212 dispatch({213 type: "UPDATE_ENTRY",214 payload: { id: entry.id, patch: { password: "new-secret" } },215 })216 ).resolves.toMatchObject({ ok: true });217218 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");219 });220221 it("UPDATE_ENTRY with an empty login password returns an error", async () => {222 const vault = await import("./vault");223 await vault.createVault("correct-pass-12345");224 const entry = await vault.addEntry({225 title: "GitHub",226 username: "june",227 password: "secret123",228 url: "https://github.com",229 });230231 await expect(232 dispatch({233 type: "UPDATE_ENTRY",234 payload: { id: entry.id, patch: { password: "" } },235 })236 ).resolves.toMatchObject({ ok: false, error: "password or passkey required" });237 });238239 it("DELETE_ENTRY soft-deletes the target entry", async () => {240 const vault = await import("./vault");241 await vault.createVault("correct-pass-12345");242 const entry = await vault.addEntry({243 title: "GitHub",244 username: "june",245 password: "secret123",246 url: "https://github.com",247 });248249 await expect(250 dispatch({251 type: "DELETE_ENTRY",252 payload: { id: entry.id },253 })254 ).resolves.toMatchObject({ ok: true });255256 expect(vault.getEntries()).toEqual([]);257 expect(vault.getDeletedEntries()).toHaveLength(1);258 });259260 it("DELETE_ENTRY of a missing id returns an error", async () => {261 const vault = await import("./vault");262 await vault.createVault("correct-pass-12345");263264 await expect(265 dispatch({266 type: "DELETE_ENTRY",267 payload: { id: "missing" },268 })269 ).resolves.toMatchObject({ ok: false, error: "entry not found" });270 });271272 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {273 const vault = await import("./vault");274 await vault.createVault("correct-pass-12345");275 const entry = await vault.addEntry({276 title: "GitHub",277 username: "june",278 password: "secret123",279 url: "https://github.com",280 });281282 const response = await dispatch({283 type: "GET_ENTRY_FOR_FILL",284 payload: { id: entry.id },285 });286287 expect(response).toMatchObject({288 ok: true,289 data: expect.objectContaining({290 id: entry.id,291 username: "june",292 password: "secret123",293 }),294 });295 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();296 });297298 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {299 const vault = await import("./vault");300 await vault.createVault("correct-pass-12345");301 vault.lock();302303 await expect(304 dispatch({305 type: "GET_ENTRY_FOR_FILL",306 payload: { id: "missing" },307 })308 ).resolves.toMatchObject({ ok: false, error: "vault locked" });309 });310311 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {312 const vault = await import("./vault");313 await vault.createVault("correct-pass-12345");314 const generated = await generatePasskeyKeypair(-7);315 const entry = await vault.addEntry({316 type: "login",317 title: "WebAuthn.io",318 passkey: {319 rpId: "webauthn.io",320 rpName: "WebAuthn.io",321 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),322 publicKeyCose: base64urlEncode(generated.publicKeyCose),323 privateKeyJwk: generated.privateKeyJwk,324 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),325 userName: "june",326 userDisplayName: "June",327 algorithm: -7,328 signCount: 0,329 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",330 backupEligible: true,331 backupState: false,332 createdAt: Date.now(),333 },334 });335336 await expect(337 dispatch({338 type: "GET_ENTRY_FOR_FILL",339 payload: { id: entry.id },340 })341 ).resolves.toMatchObject({ ok: false, error: "passkey not fillable" });342 });343344 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {345 const vault = await import("./vault");346 await vault.createVault("correct-pass-12345");347 await vault.addEntry({348 title: "GitHub",349 username: "june",350 password: "secret123",351 url: "https://github.com",352 totp: "JBSWY3DPEHPK3PXP",353 });354355 const response = await dispatch({356 type: "AUTOFILL_QUERY",357 payload: { domain: "github.com" },358 });359360 expect(response).toMatchObject({361 ok: true,362 data: [363 {364 id: expect.any(String),365 title: "GitHub",366 username: "june",367 url: "https://github.com",368 hasTotp: true,369 },370 ],371 });372 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();373 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();374 });375376 it("AUTOFILL_QUERY includeAll returns only id and title for non-note, non-passkey entries", async () => {377 const vault = await import("./vault");378 await vault.createVault("correct-pass-12345");379 await vault.addEntry({380 title: "GitHub",381 username: "june",382 password: "secret123",383 url: "https://github.com",384 });385 await vault.addEntry({386 type: "note",387 title: "Secure note",388 notes: "hello",389 });390391 const response = await dispatch({392 type: "AUTOFILL_QUERY",393 payload: { domain: "github.com", includeAll: true },394 });395396 expect(response).toMatchObject({397 ok: true,398 data: [{ id: expect.any(String), title: "GitHub" }],399 });400 expect(response.data).toHaveLength(1);401 });402403 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {404 const vault = await import("./vault");405 await vault.createVault("correct-pass-12345");406 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });407408 const response = await dispatch({409 type: "RESTORE_ENTRIES",410 payload: {411 mode: "replace",412 entries: [413 {414 id: "restored-1",415 type: "login",416 title: "B",417 username: "june",418 password: "two",419 url: "https://b.test",420 createdAt: 1,421 modifiedAt: 1,422 },423 ],424 },425 });426427 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });428 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);429 });430431 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {432 const vault = await import("./vault");433 await vault.createVault("correct-pass-12345");434435 await expect(436 dispatch({437 type: "RESTORE_ENTRIES",438 payload: { mode: "replace", entries: [] },439 })440 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });441 });442443 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {444 const vault = await import("./vault");445 await vault.createVault("correct-pass-12345");446447 await expect(448 dispatch({449 type: "CHANGE_PASSWORD",450 payload: {451 currentPassword: "correct-pass-12345",452 newPassword: "new-pass-67890",453 },454 })455 ).resolves.toMatchObject({ ok: true });456457 vault.lock();458 expect(await vault.unlock("new-pass-67890")).toBe(true);459460 await expect(461 dispatch({462 type: "CHANGE_PASSWORD",463 payload: {464 currentPassword: "wrong-pass",465 newPassword: "another-pass",466 },467 })468 ).resolves.toMatchObject({ ok: false, error: "incorrect current password" });469 });470471 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {472 const vault = await import("./vault");473 await vault.createVault("correct-pass-12345");474475 (476 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>477 ).mockClear();478 (479 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>480 ).mockClear();481482 await expect(483 dispatch({484 type: "UPDATE_SETTINGS",485 payload: { autoLockMinutes: 30 },486 })487 ).resolves.toMatchObject({ ok: true });488489 expect(vault.getSettings().autoLockMinutes).toBe(30);490 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);491 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {492 delayInMinutes: 30,493 });494 });495496 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {497 vi.useFakeTimers();498 const vault = await import("./vault");499 await vault.createVault("correct-pass-12345");500 const entry = await vault.addEntry({501 title: "GitHub",502 username: "june",503 password: "secret123",504 url: "https://github.com",505 });506 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);507508 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });509 await vi.runAllTimersAsync();510 const response = await responsePromise;511512 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });513 expect(vault.getEntryById(entry.id)?.breach).toBe(7);514 breachSpy.mockRestore();515 });516517 it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {518 const vault = await import("./vault");519 await vault.createVault("correct-pass-12345");520521 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({522 ok: false,523 error: "invalid sender",524 });525526 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({527 ok: true,528 data: [],529 });530 });531532 it("LOCK and UNLOCK messages update vault state correctly", async () => {533 const vault = await import("./vault");534535 await expect(536 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })537 ).resolves.toMatchObject({ ok: true });538 expect(vault.isUnlocked()).toBe(true);539540 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });541 expect(vault.isUnlocked()).toBe(false);542543 await expect(544 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })545 ).resolves.toMatchObject({ ok: false });546 expect(vault.isUnlocked()).toBe(false);547548 await expect(549 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })550 ).resolves.toMatchObject({ ok: true });551 expect(vault.isUnlocked()).toBe(true);552 });553554 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {555 const response = await dispatch({556 type: "FIRE_CANARY_PING",557 payload: {558 webhookUrl: "https://example.com/hook",559 entryTitle: "GitHub",560 trigger: "password_copied",561 },562 });563564 expect(response).toMatchObject({ ok: true });565 expect(fetchMock).toHaveBeenCalledWith(566 "https://example.com/hook",567 expect.objectContaining({568 method: "POST",569 headers: { "Content-Type": "application/json" },570 body: expect.any(String),571 })572 );573 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;574 expect(body).toMatchObject({575 entry: "GitHub",576 trigger: "password_copied",577 source: "peach-extension",578 });579 });580581 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {582 await expect(583 dispatch({584 type: "FIRE_CANARY_PING",585 payload: {586 webhookUrl: "http://example.com/hook",587 entryTitle: "GitHub",588 trigger: "password_copied",589 },590 })591 ).resolves.toMatchObject({ ok: false, error: "invalid webhook url" });592593 expect(fetchMock).not.toHaveBeenCalled();594 });595596 it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {597 const response = await dispatch({598 type: "SUBMIT_FEEDBACK",599 payload: {600 category: "Bug report",601 message: "the popup flashes",602 version: "2.1.0",603 browser: "Chrome",604 },605 });606607 expect(response).toMatchObject({ ok: true });608 expect(fetchMock).not.toHaveBeenCalled();609 });610611 it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {612 const response = await dispatch({613 type: "SUBMIT_FEEDBACK",614 payload: {615 category: "Bug report",616 message: " ",617 version: "2.1.0",618 browser: "Chrome",619 },620 });621622 expect(response).toMatchObject({ ok: false, error: "message is empty" });623 expect(fetchMock).not.toHaveBeenCalled();624 });625626 it("GET_IDENTITIES when locked returns vault locked error", async () => {627 const vault = await import("./vault");628 await vault.createVault("correct-pass-12345");629 vault.lock();630 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({631 ok: false,632 error: "vault locked",633 });634 });635636 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {637 const vault = await import("./vault");638 await vault.createVault("correct-pass-12345");639 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });640 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });641 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });642643 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };644 expect(resp.ok).toBe(true);645 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;646 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({647 type: "email",648 useCount: 2,649 });650 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({651 type: "username",652 useCount: 1,653 });654 655 const emailIdx = data.findIndex((d) => d.value === "[email protected]");656 const userIdx = data.findIndex((d) => d.value === "juni_patel");657 expect(emailIdx).toBeLessThan(userIdx);658 });659660 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {661 const vault = await import("./vault");662 await vault.createVault("correct-pass-12345");663 for (let i = 0; i < 3; i++) {664 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });665 }666667 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };668 const data = resp.data as Array<{ value: string; useCount: number }>;669 const hits = data.filter((d) => d.value === "[email protected]");670 expect(hits).toHaveLength(1);671 expect(hits[0]!.useCount).toBe(3);672 });673674 it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {675 const vault = await import("./vault");676 await vault.createVault("correct-pass-12345");677 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;678679 680 alarmsCreate.mockClear();681 await dispatch({ type: "GET_ENTRIES" });682 expect(alarmsCreate).toHaveBeenCalled();683684 685 alarmsCreate.mockClear();686 await dispatch({687 type: "SUBMIT_FEEDBACK",688 payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },689 });690 expect(alarmsCreate).not.toHaveBeenCalled();691 });692});