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 is 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 is required for login entries" });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 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: "passkey",317 title: "WebAuthn.io",318 rpId: "webauthn.io",319 rpName: "WebAuthn.io",320 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),321 publicKeyCose: base64urlEncode(generated.publicKeyCose),322 privateKeyJwk: generated.privateKeyJwk,323 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),324 userName: "june",325 userDisplayName: "June",326 algorithm: -7,327 signCount: 0,328 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",329 backupEligible: true,330 backupState: false,331 });332333 await expect(334 dispatch({335 type: "GET_ENTRY_FOR_FILL",336 payload: { id: entry.id },337 })338 ).resolves.toMatchObject({ ok: false, error: "passkey not fillable" });339 });340341 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {342 const vault = await import("./vault");343 await vault.createVault("correct-pass-12345");344 await vault.addEntry({345 title: "GitHub",346 username: "june",347 password: "secret123",348 url: "https://github.com",349 totp: "JBSWY3DPEHPK3PXP",350 });351352 const response = await dispatch({353 type: "AUTOFILL_QUERY",354 payload: { domain: "github.com" },355 });356357 expect(response).toMatchObject({358 ok: true,359 data: [360 {361 id: expect.any(String),362 title: "GitHub",363 username: "june",364 url: "https://github.com",365 hasTotp: true,366 },367 ],368 });369 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();370 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();371 });372373 it("AUTOFILL_QUERY includeAll returns only id and title for non-note, non-passkey entries", async () => {374 const vault = await import("./vault");375 await vault.createVault("correct-pass-12345");376 await vault.addEntry({377 title: "GitHub",378 username: "june",379 password: "secret123",380 url: "https://github.com",381 });382 await vault.addEntry({383 type: "note",384 title: "Secure note",385 notes: "hello",386 });387388 const response = await dispatch({389 type: "AUTOFILL_QUERY",390 payload: { domain: "github.com", includeAll: true },391 });392393 expect(response).toMatchObject({394 ok: true,395 data: [{ id: expect.any(String), title: "GitHub" }],396 });397 expect(response.data).toHaveLength(1);398 });399400 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {401 const vault = await import("./vault");402 await vault.createVault("correct-pass-12345");403 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });404405 const response = await dispatch({406 type: "RESTORE_ENTRIES",407 payload: {408 mode: "replace",409 entries: [410 {411 id: "restored-1",412 type: "login",413 title: "B",414 username: "june",415 password: "two",416 url: "https://b.test",417 createdAt: 1,418 modifiedAt: 1,419 },420 ],421 },422 });423424 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });425 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);426 });427428 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {429 const vault = await import("./vault");430 await vault.createVault("correct-pass-12345");431432 await expect(433 dispatch({434 type: "RESTORE_ENTRIES",435 payload: { mode: "replace", entries: [] },436 })437 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });438 });439440 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {441 const vault = await import("./vault");442 await vault.createVault("correct-pass-12345");443444 await expect(445 dispatch({446 type: "CHANGE_PASSWORD",447 payload: {448 currentPassword: "correct-pass-12345",449 newPassword: "new-pass-67890",450 },451 })452 ).resolves.toMatchObject({ ok: true });453454 vault.lock();455 expect(await vault.unlock("new-pass-67890")).toBe(true);456457 await expect(458 dispatch({459 type: "CHANGE_PASSWORD",460 payload: {461 currentPassword: "wrong-pass",462 newPassword: "another-pass",463 },464 })465 ).resolves.toMatchObject({ ok: false, error: "incorrect current password" });466 });467468 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {469 const vault = await import("./vault");470 await vault.createVault("correct-pass-12345");471472 (473 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>474 ).mockClear();475 (476 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>477 ).mockClear();478479 await expect(480 dispatch({481 type: "UPDATE_SETTINGS",482 payload: { autoLockMinutes: 30 },483 })484 ).resolves.toMatchObject({ ok: true });485486 expect(vault.getSettings().autoLockMinutes).toBe(30);487 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);488 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {489 delayInMinutes: 30,490 });491 });492493 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {494 vi.useFakeTimers();495 const vault = await import("./vault");496 await vault.createVault("correct-pass-12345");497 const entry = await vault.addEntry({498 title: "GitHub",499 username: "june",500 password: "secret123",501 url: "https://github.com",502 });503 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);504505 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });506 await vi.runAllTimersAsync();507 const response = await responsePromise;508509 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });510 expect(vault.getEntryById(entry.id)?.breach).toBe(7);511 breachSpy.mockRestore();512 });513514 it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {515 const vault = await import("./vault");516 await vault.createVault("correct-pass-12345");517518 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({519 ok: false,520 error: "invalid sender",521 });522523 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({524 ok: true,525 data: [],526 });527 });528529 it("LOCK and UNLOCK messages update vault state correctly", async () => {530 const vault = await import("./vault");531532 await expect(533 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })534 ).resolves.toMatchObject({ ok: true });535 expect(vault.isUnlocked()).toBe(true);536537 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });538 expect(vault.isUnlocked()).toBe(false);539540 await expect(541 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })542 ).resolves.toMatchObject({ ok: false });543 expect(vault.isUnlocked()).toBe(false);544545 await expect(546 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })547 ).resolves.toMatchObject({ ok: true });548 expect(vault.isUnlocked()).toBe(true);549 });550551 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {552 const response = await dispatch({553 type: "FIRE_CANARY_PING",554 payload: {555 webhookUrl: "https://example.com/hook",556 entryTitle: "GitHub",557 trigger: "password_copied",558 },559 });560561 expect(response).toMatchObject({ ok: true });562 expect(fetchMock).toHaveBeenCalledWith(563 "https://example.com/hook",564 expect.objectContaining({565 method: "POST",566 headers: { "Content-Type": "application/json" },567 body: expect.any(String),568 })569 );570 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;571 expect(body).toMatchObject({572 entry: "GitHub",573 trigger: "password_copied",574 source: "peach-extension",575 });576 });577578 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {579 await expect(580 dispatch({581 type: "FIRE_CANARY_PING",582 payload: {583 webhookUrl: "http://example.com/hook",584 entryTitle: "GitHub",585 trigger: "password_copied",586 },587 })588 ).resolves.toMatchObject({ ok: false, error: "invalid webhook url" });589590 expect(fetchMock).not.toHaveBeenCalled();591 });592});