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 clear: vi.fn(async () => {79 for (const key of Object.keys(storage)) delete storage[key];80 }),81 },82 session: {83 get: vi.fn(async (key?: string | string[]) => {84 if (!key) return sessionStorage;85 if (typeof key === "string") {86 return { [key]: sessionStorage[key] };87 }88 return key.reduce<Record<string, unknown>>((result, entry) => {89 result[entry] = sessionStorage[entry];90 return result;91 }, {});92 }),93 set: vi.fn(async (next: Record<string, unknown>) => {94 Object.assign(sessionStorage, next);95 }),96 remove: vi.fn(async (keys: string | string[]) => {97 const list = Array.isArray(keys) ? keys : [keys];98 for (const key of list) {99 delete sessionStorage[key];100 }101 }),102 clear: vi.fn(async () => {103 for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];104 }),105 },106 },107 alarms: {108 clear: vi.fn(),109 create: vi.fn(),110 onAlarm: { addListener: vi.fn() },111 },112 idle: {113 setDetectionInterval: vi.fn(),114 onStateChanged: { addListener: vi.fn() },115 },116 scripting: {117 executeScript: vi.fn(async () => []),118 },119 } as unknown as typeof chrome;120121 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;122123 const messages = await import("./messages");124 messages.setupMessageHandler();125 const vault = await import("./vault");126 await vault.ensureSettingsLoaded();127 });128129 async function dispatch(130 message: Message,131 sender: chrome.runtime.MessageSender = { id: "peach-extension" }132 ): Promise<MessageResponse> {133 return await new Promise<MessageResponse>((resolve) => {134 const keepChannelOpen = listener(message, sender, (response) => resolve(response));135 expect(keepChannelOpen).toBe(true);136 });137 }138139 it("GET_ENTRIES when unlocked returns the entry array", async () => {140 const vault = await import("./vault");141 await vault.createVault("correct-pass-12345");142 await vault.addEntry({143 title: "GitHub",144 username: "june",145 password: "secret123",146 url: "https://github.com",147 });148149 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({150 ok: true,151 data: [152 expect.objectContaining({153 title: "GitHub",154 username: "june",155 }),156 ],157 });158 });159160 it("GET_ENTRIES when locked returns a vault locked error", async () => {161 const vault = await import("./vault");162 await vault.createVault("correct-pass-12345");163 vault.lock();164165 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({166 ok: false,167 error: "vault_locked",168 });169 });170171 it("ADD_ENTRY with a valid payload returns the created entry", async () => {172 const vault = await import("./vault");173 await vault.createVault("correct-pass-12345");174175 const response = await dispatch({176 type: "ADD_ENTRY",177 payload: {178 title: "GitHub",179 username: "june",180 password: "secret123",181 url: "https://github.com",182 },183 });184185 expect(response).toMatchObject({186 ok: true,187 data: expect.objectContaining({188 id: expect.any(String),189 title: "GitHub",190 }),191 });192 expect(vault.getEntries()).toHaveLength(1);193 });194195 it("ADD_ENTRY when locked returns a vault locked error", async () => {196 const vault = await import("./vault");197 await vault.createVault("correct-pass-12345");198 vault.lock();199200 await expect(201 dispatch({202 type: "ADD_ENTRY",203 payload: {204 title: "GitHub",205 username: "june",206 password: "secret123",207 url: "https://github.com",208 },209 })210 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });211 });212213 it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {214 const vault = await import("./vault");215 await vault.createVault("correct-pass-12345");216217 await expect(218 dispatch({219 type: "ADD_ENTRY",220 payload: {221 title: "GitHub",222 username: "june",223 password: "",224 url: "https://github.com",225 },226 })227 ).resolves.toMatchObject({ ok: true });228 });229230 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {231 const vault = await import("./vault");232 await vault.createVault("correct-pass-12345");233 const entry = await vault.addEntry({234 title: "GitHub",235 username: "june",236 password: "secret123",237 url: "https://github.com",238 });239240 await expect(241 dispatch({242 type: "UPDATE_ENTRY",243 payload: { id: entry.id, patch: { password: "new-secret" } },244 })245 ).resolves.toMatchObject({ ok: true });246247 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");248 });249250 it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {251 const vault = await import("./vault");252 await vault.createVault("correct-pass-12345");253 const entry = await vault.addEntry({254 title: "GitHub",255 username: "june",256 password: "secret123",257 url: "https://github.com",258 });259260 await expect(261 dispatch({262 type: "UPDATE_ENTRY",263 payload: { id: entry.id, patch: { password: "" } },264 })265 ).resolves.toMatchObject({ ok: true });266267 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();268 });269270 it("DELETE_ENTRY soft-deletes the target entry", async () => {271 const vault = await import("./vault");272 await vault.createVault("correct-pass-12345");273 const entry = await vault.addEntry({274 title: "GitHub",275 username: "june",276 password: "secret123",277 url: "https://github.com",278 });279280 await expect(281 dispatch({282 type: "DELETE_ENTRY",283 payload: { id: entry.id },284 })285 ).resolves.toMatchObject({ ok: true });286287 expect(vault.getEntries()).toEqual([]);288 expect(vault.getDeletedEntries()).toHaveLength(1);289 });290291 it("DELETE_ENTRY of a missing id returns an error", async () => {292 const vault = await import("./vault");293 await vault.createVault("correct-pass-12345");294295 await expect(296 dispatch({297 type: "DELETE_ENTRY",298 payload: { id: "missing" },299 })300 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });301 });302303 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {304 const vault = await import("./vault");305 await vault.createVault("correct-pass-12345");306 const entry = await vault.addEntry({307 title: "GitHub",308 username: "june",309 password: "secret123",310 url: "https://github.com",311 });312313 const response = await dispatch({314 type: "GET_ENTRY_FOR_FILL",315 payload: { id: entry.id },316 });317318 expect(response).toMatchObject({319 ok: true,320 data: expect.objectContaining({321 id: entry.id,322 username: "june",323 password: "secret123",324 }),325 });326 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();327 });328329 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {330 const vault = await import("./vault");331 await vault.createVault("correct-pass-12345");332 vault.lock();333334 await expect(335 dispatch({336 type: "GET_ENTRY_FOR_FILL",337 payload: { id: "missing" },338 })339 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });340 });341342 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {343 const vault = await import("./vault");344 await vault.createVault("correct-pass-12345");345 const generated = await generatePasskeyKeypair(-7);346 const entry = await vault.addEntry({347 type: "login",348 title: "WebAuthn.io",349 passkey: {350 rpId: "webauthn.io",351 rpName: "WebAuthn.io",352 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),353 publicKeyCose: base64urlEncode(generated.publicKeyCose),354 privateKeyJwk: generated.privateKeyJwk,355 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),356 userName: "june",357 userDisplayName: "June",358 algorithm: -7,359 signCount: 0,360 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",361 backupEligible: true,362 backupState: false,363 createdAt: Date.now(),364 },365 });366367 await expect(368 dispatch({369 type: "GET_ENTRY_FOR_FILL",370 payload: { id: entry.id },371 })372 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });373 });374375 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {376 const vault = await import("./vault");377 await vault.createVault("correct-pass-12345");378 await vault.addEntry({379 title: "GitHub",380 username: "june",381 password: "secret123",382 url: "https://github.com",383 totp: "JBSWY3DPEHPK3PXP",384 });385386 const response = await dispatch({387 type: "AUTOFILL_QUERY",388 payload: { domain: "github.com" },389 });390391 expect(response).toMatchObject({392 ok: true,393 data: [394 {395 id: expect.any(String),396 title: "GitHub",397 username: "june",398 url: "https://github.com",399 hasTotp: true,400 },401 ],402 });403 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();404 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();405 });406407 it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {408 const vault = await import("./vault");409 await vault.createVault("correct-pass-12345");410 await vault.addEntry({411 title: "GitHub",412 username: "june",413 password: "secret123",414 url: "https://github.com",415 });416 await vault.addEntry({417 type: "note",418 title: "Secure note",419 notes: "hello",420 });421422 const response = await dispatch({423 type: "AUTOFILL_QUERY",424 payload: { domain: "github.com", includeAll: true },425 });426427 expect(response).toMatchObject({428 ok: true,429 data: [430 {431 id: expect.any(String),432 title: "GitHub",433 username: "june",434 url: "https://github.com",435 hasPasskey: false,436 },437 ],438 });439 const data = response.data as Array<Record<string, unknown>>;440 expect(data).toHaveLength(1);441 442 expect(data[0]).not.toHaveProperty("password");443 expect(data[0]).not.toHaveProperty("totp");444 });445446 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {447 const vault = await import("./vault");448 await vault.createVault("correct-pass-12345");449 await vault.addEntry({450 title: "GitHub",451 username: "june",452 password: "secret123",453 url: "https://github.com",454 totp: "JBSWY3DPEHPK3PXP",455 });456 await vault.addEntry({457 title: "No 2FA",458 username: "person",459 password: "secret123",460 url: "https://github.com",461 });462463 const response = await dispatch({464 type: "AUTOFILL_QUERY",465 payload: { domain: "github.com", kind: "totp" },466 });467468 expect(response).toMatchObject({469 ok: true,470 data: [471 {472 id: expect.any(String),473 title: "GitHub",474 username: "june",475 url: "https://github.com",476 hasTotp: true,477 },478 ],479 });480 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();481 expect(response.data).toHaveLength(1);482 });483484 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {485 const vault = await import("./vault");486 await vault.createVault("correct-pass-12345");487 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });488489 const response = await dispatch({490 type: "RESTORE_ENTRIES",491 payload: {492 mode: "replace",493 entries: [494 {495 id: "restored-1",496 type: "login",497 title: "B",498 username: "june",499 password: "two",500 url: "https://b.test",501 createdAt: 1,502 modifiedAt: 1,503 },504 ],505 },506 });507508 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });509 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);510 });511512 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {513 const vault = await import("./vault");514 await vault.createVault("correct-pass-12345");515516 await expect(517 dispatch({518 type: "RESTORE_ENTRIES",519 payload: { mode: "replace", entries: [] },520 })521 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });522 });523524 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {525 const vault = await import("./vault");526 await vault.createVault("correct-pass-12345");527528 await expect(529 dispatch({530 type: "CHANGE_PASSWORD",531 payload: {532 currentPassword: "correct-pass-12345",533 newPassword: "new-pass-67890",534 },535 })536 ).resolves.toMatchObject({ ok: true });537538 vault.lock();539 expect(await vault.unlock("new-pass-67890")).toBe(true);540541 await expect(542 dispatch({543 type: "CHANGE_PASSWORD",544 payload: {545 currentPassword: "wrong-pass",546 newPassword: "another-pass",547 },548 })549 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });550 });551552 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {553 const vault = await import("./vault");554 await vault.createVault("correct-pass-12345");555556 (557 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>558 ).mockClear();559 (560 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>561 ).mockClear();562563 await expect(564 dispatch({565 type: "UPDATE_SETTINGS",566 payload: { autoLockMinutes: 30 },567 })568 ).resolves.toMatchObject({ ok: true });569570 expect(vault.getSettings().autoLockMinutes).toBe(30);571 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);572 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {573 delayInMinutes: 30,574 });575 });576577 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {578 vi.useFakeTimers();579 const vault = await import("./vault");580 await vault.createVault("correct-pass-12345");581 const entry = await vault.addEntry({582 title: "GitHub",583 username: "june",584 password: "secret123",585 url: "https://github.com",586 });587 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);588589 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });590 await vi.runAllTimersAsync();591 const response = await responsePromise;592593 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });594 expect(vault.getEntryById(entry.id)?.breach).toBe(7);595 breachSpy.mockRestore();596 });597598 it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {599 const vault = await import("./vault");600 await vault.createVault("correct-pass-12345");601602 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({603 ok: false,604 error: "invalid_sender",605 });606607 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({608 ok: true,609 data: [],610 });611 });612613 it("LOCK and UNLOCK messages update vault state correctly", async () => {614 const vault = await import("./vault");615616 await expect(617 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })618 ).resolves.toMatchObject({ ok: true });619 expect(vault.isUnlocked()).toBe(true);620621 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });622 expect(vault.isUnlocked()).toBe(false);623624 await expect(625 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })626 ).resolves.toMatchObject({ ok: false });627 expect(vault.isUnlocked()).toBe(false);628629 await expect(630 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })631 ).resolves.toMatchObject({ ok: true });632 expect(vault.isUnlocked()).toBe(true);633 });634635 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {636 const response = await dispatch({637 type: "FIRE_CANARY_PING",638 payload: {639 webhookUrl: "https://example.com/hook",640 entryTitle: "GitHub",641 trigger: "password_copied",642 },643 });644645 expect(response).toMatchObject({ ok: true });646 expect(fetchMock).toHaveBeenCalledWith(647 "https://example.com/hook",648 expect.objectContaining({649 method: "POST",650 headers: { "Content-Type": "application/json" },651 body: expect.any(String),652 })653 );654 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;655 expect(body).toMatchObject({656 entry: "GitHub",657 trigger: "password_copied",658 source: "peach-extension",659 });660 });661662 it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {663 const payload = {664 webhookUrl: "https://example.com/hook",665 entryTitle: "GitHub",666 trigger: "autofilled" as const,667 };668 const first = await dispatch({ type: "FIRE_CANARY_PING", payload });669 expect(first).toMatchObject({ ok: true });670 expect(fetchMock).toHaveBeenCalledTimes(1);671672 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });673 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });674 expect(fetchMock).toHaveBeenCalledTimes(1); 675 });676677 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {678 await dispatch({679 type: "FIRE_CANARY_PING",680 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },681 });682 await dispatch({683 type: "FIRE_CANARY_PING",684 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },685 });686 expect(fetchMock).toHaveBeenCalledTimes(2);687 });688689 it("LV-3: test pings are exempt from the debounce", async () => {690 const payload = {691 webhookUrl: "https://example.com/hook",692 entryTitle: "GitHub",693 trigger: "test" as const,694 };695 await dispatch({ type: "FIRE_CANARY_PING", payload });696 await dispatch({ type: "FIRE_CANARY_PING", payload });697 expect(fetchMock).toHaveBeenCalledTimes(2);698 });699700 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {701 await expect(702 dispatch({703 type: "FIRE_CANARY_PING",704 payload: {705 webhookUrl: "http://example.com/hook",706 entryTitle: "GitHub",707 trigger: "password_copied",708 },709 })710 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });711712 expect(fetchMock).not.toHaveBeenCalled();713 });714715 it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {716 const response = await dispatch({717 type: "SUBMIT_FEEDBACK",718 payload: {719 category: "Bug report",720 message: "the popup flashes",721 version: "2.1.0",722 browser: "Chrome",723 },724 });725726 expect(response).toMatchObject({ ok: true });727 expect(fetchMock).not.toHaveBeenCalled();728 });729730 it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {731 const response = await dispatch({732 type: "SUBMIT_FEEDBACK",733 payload: {734 category: "Bug report",735 message: " ",736 version: "2.1.0",737 browser: "Chrome",738 },739 });740741 expect(response).toMatchObject({ ok: false, error: "message_empty" });742 expect(fetchMock).not.toHaveBeenCalled();743 });744745 it("GET_IDENTITIES when locked returns vault locked error", async () => {746 const vault = await import("./vault");747 await vault.createVault("correct-pass-12345");748 vault.lock();749 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({750 ok: false,751 error: "vault_locked",752 });753 });754755 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {756 const vault = await import("./vault");757 await vault.createVault("correct-pass-12345");758 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });759 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });760 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });761762 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };763 expect(resp.ok).toBe(true);764 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;765 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({766 type: "email",767 useCount: 2,768 });769 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({770 type: "username",771 useCount: 1,772 });773 774 const emailIdx = data.findIndex((d) => d.value === "[email protected]");775 const userIdx = data.findIndex((d) => d.value === "juni_patel");776 expect(emailIdx).toBeLessThan(userIdx);777 });778779 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {780 const vault = await import("./vault");781 await vault.createVault("correct-pass-12345");782 for (let i = 0; i < 3; i++) {783 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });784 }785786 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };787 const data = resp.data as Array<{ value: string; useCount: number }>;788 const hits = data.filter((d) => d.value === "[email protected]");789 expect(hits).toHaveLength(1);790 expect(hits[0]!.useCount).toBe(3);791 });792793 it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {794 const vault = await import("./vault");795 await vault.createVault("correct-pass-12345");796 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;797798 799 alarmsCreate.mockClear();800 await dispatch({ type: "GET_ENTRIES" });801 expect(alarmsCreate).toHaveBeenCalled();802803 804 alarmsCreate.mockClear();805 await dispatch({806 type: "SUBMIT_FEEDBACK",807 payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },808 });809 expect(alarmsCreate).not.toHaveBeenCalled();810 });811812 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {813 const vault = await import("./vault");814 await vault.createVault("correct-pass-12345");815 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });816817 818 storage.peach_license = "PEACH-PRO-TEST-KEY";819 storage.peach_pro_celebrated = true;820821 const res = await dispatch({ type: "DESTROY_VAULT" });822 expect(res).toMatchObject({ ok: true });823824 825 expect(storage.peach_vault_a).toBeUndefined();826 827 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");828 expect(storage.peach_pro_celebrated).toBe(true);829 });830831 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {832 const messages = await import("./messages");833 const vault = await import("./vault");834835 let resolveGate: () => void = () => {};836 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));837838 let settled = false;839 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });840841 842 843 await new Promise((r) => setTimeout(r, 20));844 expect(settled).toBe(false);845846 847 await vault.createVault("correct-pass-12345");848 resolveGate();849850 await expect(pending).resolves.toMatchObject({ ok: true, data: true });851 });852853 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {854 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });855 expect(res.ok).toBe(false);856 857 const has = await dispatch({ type: "HAS_VAULT" });858 expect((has as { data?: boolean }).data).toBe(false);859 });860861 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {862 const vault = await import("./vault");863 await vault.createVault("correct-pass-12345");864 const e = await vault.addEntry({865 title: "Mail",866 username: "june",867 password: "pw",868 url: "https://mail.test",869 totp: "JBSWY3DPEHPK3PXP",870 });871872 const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });873 expect(res.ok).toBe(true);874 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };875 expect(data.totp).toBeUndefined();876 expect(data.totpCode).toMatch(/^\d{6}$/);877 expect(data.totpRemaining).toBeGreaterThan(0);878 expect(data.totpRemaining).toBeLessThanOrEqual(30);879 });880881 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {882 const vault = await import("./vault");883 await vault.createVault("correct-pass-12345");884 const e = await vault.addEntry({885 title: "Bank",886 username: "june",887 password: "old-pw",888 url: "https://bank.test",889 });890 await vault.updateEntry(e.id, { password: "new-pw" });891892 const fill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });893 expect(fill.ok).toBe(true);894 895 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();896897 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });898 expect(edit.ok).toBe(true);899 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];900 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);901 });902903 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {904 const vault = await import("./vault");905 await vault.createVault("correct-pass-12345");906 const e = await vault.addEntry({907 title: "Mail",908 username: "june",909 password: "pw",910 url: "https://mail.test",911 totp: "JBSWY3DPEHPK3PXP",912 });913914 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });915 expect(res.ok).toBe(true);916 const data = res.data as { totp?: string };917 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");918 });919920 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {921 const vault = await import("./vault");922 await vault.createVault("correct-pass-12345");923924 const res = await dispatch({ type: "DESTROY_VAULT" });925 expect(res).toMatchObject({ ok: true });926927 expect(storage.peach_vault_a).toBeUndefined();928 expect(storage.peach_license).toBeUndefined();929 expect(storage.peach_pro_celebrated).toBeUndefined();930 });931932 933934 function tabSender(url: string): chrome.runtime.MessageSender {935 return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };936 }937938 it("MV-1: tab request with a matching domain returns the entry", async () => {939 const vault = await import("./vault");940 await vault.createVault("correct-pass-12345");941 const entry = await vault.addEntry({942 title: "GitHub",943 username: "june",944 password: "secret123",945 url: "https://github.com",946 });947948 const res = await dispatch(949 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },950 tabSender("https://github.com/login"),951 );952 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });953 });954955 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {956 const vault = await import("./vault");957 await vault.createVault("correct-pass-12345");958 const entry = await vault.addEntry({959 title: "GitHub",960 username: "june",961 password: "secret123",962 url: "https://github.com",963 });964965 const res = await dispatch(966 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },967 tabSender("https://evil.example.com/phish"),968 );969 970 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });971 });972973 it("MV-1: mismatched domain with explicitSummon:true returns the entry (user-chosen off-domain fill)", async () => {974 const vault = await import("./vault");975 await vault.createVault("correct-pass-12345");976 const entry = await vault.addEntry({977 title: "GitHub",978 username: "june",979 password: "secret123",980 url: "https://github.com",981 });982983 const res = await dispatch(984 {985 type: "GET_ENTRY_FOR_FILL",986 payload: { id: entry.id, explicitSummon: true },987 },988 tabSender("https://evil.example.com/phish"),989 );990 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });991 });992993 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {994 const vault = await import("./vault");995 await vault.createVault("correct-pass-12345");996 const entry = await vault.addEntry({997 title: "GitHub",998 username: "june",999 password: "secret123",1000 url: "https://github.com",1001 });10021003 const res = await dispatch(1004 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1005 tabSender("not a url"),1006 );1007 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1008 });10091010 10111012 async function seedTwoDomainVault() {1013 const vault = await import("./vault");1014 await vault.createVault("correct-pass-12345");1015 await vault.addEntry({1016 title: "GitHub",1017 username: "june",1018 password: "gh",1019 url: "https://github.com",1020 });1021 await vault.addEntry({1022 title: "Example",1023 username: "june",1024 password: "ex",1025 url: "https://example.com",1026 });1027 }10281029 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1030 await seedTwoDomainVault();10311032 const res = await dispatch({1033 type: "AUTOFILL_QUERY",1034 payload: { domain: "github.com", includeAll: true },1035 });1036 expect(res.ok).toBe(true);1037 const data = res.data as Array<{ title: string }>;1038 1039 expect(data).toHaveLength(1);1040 expect(data[0].title).toBe("GitHub");1041 });10421043 it("MV-2: AUTOFILL_QUERY with includeAll + userGesture returns the vault-wide list", async () => {1044 await seedTwoDomainVault();10451046 const res = await dispatch({1047 type: "AUTOFILL_QUERY",1048 payload: { domain: "github.com", includeAll: true, userGesture: true },1049 });1050 expect(res.ok).toBe(true);1051 const data = res.data as Array<{ title: string }>;1052 expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);1053 });10541055 10561057 async function seedPendingVouched(deviceId: string) {1058 const { addPendingVouchedDevice } = await import(1059 "./pending-vouched-devices"1060 );1061 await addPendingVouchedDevice({1062 deviceId,1063 deviceName: "New Pixel",1064 deviceType: "android",1065 longTermPublicKeyHex: "e".repeat(64),1066 signingPublicKeyHex: "a".repeat(64),1067 wsPort: 51234,1068 pairedAt: 1_700_000_000_020,1069 receivedAt: 1_700_000_000_050,1070 vouchedByDeviceId: "extension-issuer",1071 });1072 }10731074 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1075 const vault = await import("./vault");1076 await vault.createVault("correct-pass-12345");1077 await seedPendingVouched("android-new");1078 const { findPairedDeviceById } = await import("./paired-devices");1079 const { listPendingVouchedDevices } = await import(1080 "./pending-vouched-devices"1081 );10821083 const res = await dispatch({1084 type: "CONFIRM_VOUCHED_DEVICE",1085 payload: { deviceId: "android-new" },1086 });1087 expect(res).toMatchObject({ ok: true });10881089 expect(await findPairedDeviceById("android-new")).toMatchObject({1090 deviceName: "New Pixel",1091 trustSource: "vouched",1092 longTermPublicKeyHex: "e".repeat(64),1093 });1094 expect(await listPendingVouchedDevices()).toHaveLength(0);1095 });10961097 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1098 const vault = await import("./vault");1099 await vault.createVault("correct-pass-12345");1100 await seedPendingVouched("android-new");1101 const { findPairedDeviceById } = await import("./paired-devices");1102 const { listPendingVouchedDevices } = await import(1103 "./pending-vouched-devices"1104 );11051106 const res = await dispatch({1107 type: "REJECT_VOUCHED_DEVICE",1108 payload: { deviceId: "android-new" },1109 });1110 expect(res).toMatchObject({ ok: true });11111112 expect(await findPairedDeviceById("android-new")).toBeNull();1113 expect(await listPendingVouchedDevices()).toHaveLength(0);1114 });1115});