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 getURL: (p: string) => `chrome-extension://peach-extension/${p}`,40 onMessage: {41 addListener: vi.fn((fn: MessageListener) => {42 listener = fn;43 }),44 },45 sendMessage: vi.fn(async () => ({ ok: true })),46 },47 tabs: {48 query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {49 if (typeof callback === "function") {50 callback(tabs);51 return;52 }53 return Promise.resolve(tabs);54 }),55 sendMessage: vi.fn(async () => ({ ok: true })),56 },57 storage: {58 local: {59 get: vi.fn(async (key?: string | string[]) => {60 if (!key) return storage;61 if (typeof key === "string") {62 return { [key]: storage[key] };63 }6465 return key.reduce<Record<string, unknown>>((result, entry) => {66 result[entry] = storage[entry];67 return result;68 }, {});69 }),70 set: vi.fn(async (next: Record<string, unknown>) => {71 Object.assign(storage, next);72 }),73 remove: vi.fn(async (keys: string | string[]) => {74 const list = Array.isArray(keys) ? keys : [keys];75 for (const key of list) {76 delete storage[key];77 }78 }),79 clear: vi.fn(async () => {80 for (const key of Object.keys(storage)) delete storage[key];81 }),82 },83 session: {84 get: vi.fn(async (key?: string | string[]) => {85 if (!key) return sessionStorage;86 if (typeof key === "string") {87 return { [key]: sessionStorage[key] };88 }89 return key.reduce<Record<string, unknown>>((result, entry) => {90 result[entry] = sessionStorage[entry];91 return result;92 }, {});93 }),94 set: vi.fn(async (next: Record<string, unknown>) => {95 Object.assign(sessionStorage, next);96 }),97 remove: vi.fn(async (keys: string | string[]) => {98 const list = Array.isArray(keys) ? keys : [keys];99 for (const key of list) {100 delete sessionStorage[key];101 }102 }),103 clear: vi.fn(async () => {104 for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];105 }),106 },107 },108 alarms: {109 clear: vi.fn(),110 create: vi.fn(),111 onAlarm: { addListener: vi.fn() },112 },113 idle: {114 setDetectionInterval: vi.fn(),115 onStateChanged: { addListener: vi.fn() },116 },117 scripting: {118 executeScript: vi.fn(async () => []),119 },120 } as unknown as typeof chrome;121122 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;123124 const messages = await import("./messages");125 messages.setupMessageHandler();126 const vault = await import("./vault");127 await vault.ensureSettingsLoaded();128 });129130 async function dispatch(131 message: Message,132 sender: chrome.runtime.MessageSender = { id: "peach-extension" }133 ): Promise<MessageResponse> {134 return await new Promise<MessageResponse>((resolve) => {135 const keepChannelOpen = listener(message, sender, (response) => resolve(response));136 expect(keepChannelOpen).toBe(true);137 });138 }139140 it("GET_ENTRIES when unlocked returns the entry array", async () => {141 const vault = await import("./vault");142 await vault.createVault("correct-pass-12345");143 await vault.addEntry({144 title: "GitHub",145 username: "june",146 password: "secret123",147 url: "https://github.com",148 });149150 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({151 ok: true,152 data: [153 expect.objectContaining({154 title: "GitHub",155 username: "june",156 }),157 ],158 });159 });160161 it("GET_ENTRIES when locked returns a vault locked error", async () => {162 const vault = await import("./vault");163 await vault.createVault("correct-pass-12345");164 vault.lock();165166 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({167 ok: false,168 error: "vault_locked",169 });170 });171172 it("ADD_ENTRY with a valid payload returns the created entry", async () => {173 const vault = await import("./vault");174 await vault.createVault("correct-pass-12345");175176 const response = await dispatch({177 type: "ADD_ENTRY",178 payload: {179 title: "GitHub",180 username: "june",181 password: "secret123",182 url: "https://github.com",183 },184 });185186 expect(response).toMatchObject({187 ok: true,188 data: expect.objectContaining({189 id: expect.any(String),190 title: "GitHub",191 }),192 });193 expect(vault.getEntries()).toHaveLength(1);194 });195196 it("ADD_ENTRY when locked returns a vault locked error", async () => {197 const vault = await import("./vault");198 await vault.createVault("correct-pass-12345");199 vault.lock();200201 await expect(202 dispatch({203 type: "ADD_ENTRY",204 payload: {205 title: "GitHub",206 username: "june",207 password: "secret123",208 url: "https://github.com",209 },210 })211 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });212 });213214 it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {215 const vault = await import("./vault");216 await vault.createVault("correct-pass-12345");217218 await expect(219 dispatch({220 type: "ADD_ENTRY",221 payload: {222 title: "GitHub",223 username: "june",224 password: "",225 url: "https://github.com",226 },227 })228 ).resolves.toMatchObject({ ok: true });229 });230231 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {232 const vault = await import("./vault");233 await vault.createVault("correct-pass-12345");234 const entry = await vault.addEntry({235 title: "GitHub",236 username: "june",237 password: "secret123",238 url: "https://github.com",239 });240241 await expect(242 dispatch({243 type: "UPDATE_ENTRY",244 payload: { id: entry.id, patch: { password: "new-secret" } },245 })246 ).resolves.toMatchObject({ ok: true });247248 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");249 });250251 it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {252 const vault = await import("./vault");253 await vault.createVault("correct-pass-12345");254 const entry = await vault.addEntry({255 title: "GitHub",256 username: "june",257 password: "secret123",258 url: "https://github.com",259 });260261 await expect(262 dispatch({263 type: "UPDATE_ENTRY",264 payload: { id: entry.id, patch: { password: "" } },265 })266 ).resolves.toMatchObject({ ok: true });267268 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();269 });270271 it("DELETE_ENTRY soft-deletes the target entry", async () => {272 const vault = await import("./vault");273 await vault.createVault("correct-pass-12345");274 const entry = await vault.addEntry({275 title: "GitHub",276 username: "june",277 password: "secret123",278 url: "https://github.com",279 });280281 await expect(282 dispatch({283 type: "DELETE_ENTRY",284 payload: { id: entry.id },285 })286 ).resolves.toMatchObject({ ok: true });287288 expect(vault.getEntries()).toEqual([]);289 expect(vault.getDeletedEntries()).toHaveLength(1);290 });291292 it("DELETE_ENTRY of a missing id returns an error", async () => {293 const vault = await import("./vault");294 await vault.createVault("correct-pass-12345");295296 await expect(297 dispatch({298 type: "DELETE_ENTRY",299 payload: { id: "missing" },300 })301 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });302 });303304 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {305 const vault = await import("./vault");306 await vault.createVault("correct-pass-12345");307 const entry = await vault.addEntry({308 title: "GitHub",309 username: "june",310 password: "secret123",311 url: "https://github.com",312 });313314 const response = await dispatch({315 type: "GET_ENTRY_FOR_FILL",316 payload: { id: entry.id },317 });318319 expect(response).toMatchObject({320 ok: true,321 data: expect.objectContaining({322 id: entry.id,323 username: "june",324 password: "secret123",325 }),326 });327 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();328 });329330 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {331 const vault = await import("./vault");332 await vault.createVault("correct-pass-12345");333 vault.lock();334335 await expect(336 dispatch({337 type: "GET_ENTRY_FOR_FILL",338 payload: { id: "missing" },339 })340 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });341 });342343 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {344 const vault = await import("./vault");345 await vault.createVault("correct-pass-12345");346 const generated = await generatePasskeyKeypair(-7);347 const entry = await vault.addEntry({348 type: "login",349 title: "WebAuthn.io",350 passkey: {351 rpId: "webauthn.io",352 rpName: "WebAuthn.io",353 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),354 publicKeyCose: base64urlEncode(generated.publicKeyCose),355 privateKeyJwk: generated.privateKeyJwk,356 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),357 userName: "june",358 userDisplayName: "June",359 algorithm: -7,360 signCount: 0,361 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",362 backupEligible: true,363 backupState: false,364 createdAt: Date.now(),365 },366 });367368 await expect(369 dispatch({370 type: "GET_ENTRY_FOR_FILL",371 payload: { id: entry.id },372 })373 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });374 });375376 it("AUTOFILL_QUERY returns matched domain metadata without secrets", 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 totp: "JBSWY3DPEHPK3PXP",385 });386387 const response = await dispatch({388 type: "AUTOFILL_QUERY",389 payload: { domain: "github.com" },390 });391392 expect(response).toMatchObject({393 ok: true,394 data: [395 {396 id: expect.any(String),397 title: "GitHub",398 username: "june",399 url: "https://github.com",400 hasTotp: true,401 },402 ],403 });404 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();405 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();406 });407408 it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {409 const vault = await import("./vault");410 await vault.createVault("correct-pass-12345");411 await vault.addEntry({412 title: "GitHub",413 username: "june",414 password: "secret123",415 url: "https://github.com",416 });417 await vault.addEntry({418 type: "note",419 title: "Secure note",420 notes: "hello",421 });422423 const response = await dispatch({424 type: "AUTOFILL_QUERY",425 payload: { domain: "github.com", includeAll: true },426 });427428 expect(response).toMatchObject({429 ok: true,430 data: [431 {432 id: expect.any(String),433 title: "GitHub",434 username: "june",435 url: "https://github.com",436 hasPasskey: false,437 },438 ],439 });440 const data = response.data as Array<Record<string, unknown>>;441 expect(data).toHaveLength(1);442 443 expect(data[0]).not.toHaveProperty("password");444 expect(data[0]).not.toHaveProperty("totp");445 });446447 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {448 const vault = await import("./vault");449 await vault.createVault("correct-pass-12345");450 await vault.addEntry({451 title: "GitHub",452 username: "june",453 password: "secret123",454 url: "https://github.com",455 totp: "JBSWY3DPEHPK3PXP",456 });457 await vault.addEntry({458 title: "No 2FA",459 username: "person",460 password: "secret123",461 url: "https://github.com",462 });463464 const response = await dispatch({465 type: "AUTOFILL_QUERY",466 payload: { domain: "github.com", kind: "totp" },467 });468469 expect(response).toMatchObject({470 ok: true,471 data: [472 {473 id: expect.any(String),474 title: "GitHub",475 username: "june",476 url: "https://github.com",477 hasTotp: true,478 },479 ],480 });481 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();482 expect(response.data).toHaveLength(1);483 });484485 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {486 const vault = await import("./vault");487 await vault.createVault("correct-pass-12345");488 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });489490 const response = await dispatch({491 type: "RESTORE_ENTRIES",492 payload: {493 mode: "replace",494 entries: [495 {496 id: "restored-1",497 type: "login",498 title: "B",499 username: "june",500 password: "two",501 url: "https://b.test",502 createdAt: 1,503 modifiedAt: 1,504 },505 ],506 },507 });508509 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });510 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);511 });512513 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {514 const vault = await import("./vault");515 await vault.createVault("correct-pass-12345");516517 await expect(518 dispatch({519 type: "RESTORE_ENTRIES",520 payload: { mode: "replace", entries: [] },521 })522 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });523 });524525 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {526 const vault = await import("./vault");527 await vault.createVault("correct-pass-12345");528529 await expect(530 dispatch({531 type: "CHANGE_PASSWORD",532 payload: {533 currentPassword: "correct-pass-12345",534 newPassword: "new-pass-67890",535 },536 })537 ).resolves.toMatchObject({ ok: true });538539 vault.lock();540 expect(await vault.unlock("new-pass-67890")).toBe(true);541542 await expect(543 dispatch({544 type: "CHANGE_PASSWORD",545 payload: {546 currentPassword: "wrong-pass",547 newPassword: "another-pass",548 },549 })550 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });551 });552553 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {554 const vault = await import("./vault");555 await vault.createVault("correct-pass-12345");556557 (558 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>559 ).mockClear();560 (561 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>562 ).mockClear();563564 await expect(565 dispatch({566 type: "UPDATE_SETTINGS",567 payload: { autoLockMinutes: 30 },568 })569 ).resolves.toMatchObject({ ok: true });570571 expect(vault.getSettings().autoLockMinutes).toBe(30);572 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);573 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {574 delayInMinutes: 30,575 });576 });577578 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {579 vi.useFakeTimers();580 const vault = await import("./vault");581 await vault.createVault("correct-pass-12345");582 const entry = await vault.addEntry({583 title: "GitHub",584 username: "june",585 password: "secret123",586 url: "https://github.com",587 });588 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);589590 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });591 await vi.runAllTimersAsync();592 const response = await responsePromise;593594 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });595 expect(vault.getEntryById(entry.id)?.breach).toBe(7);596 breachSpy.mockRestore();597 });598599 it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {600 const vault = await import("./vault");601 await vault.createVault("correct-pass-12345");602603 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({604 ok: false,605 error: "invalid_sender",606 });607608 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({609 ok: true,610 data: [],611 });612 });613614 it("LOCK and UNLOCK messages update vault state correctly", async () => {615 const vault = await import("./vault");616617 await expect(618 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })619 ).resolves.toMatchObject({ ok: true });620 expect(vault.isUnlocked()).toBe(true);621622 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });623 expect(vault.isUnlocked()).toBe(false);624625 await expect(626 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })627 ).resolves.toMatchObject({ ok: false });628 expect(vault.isUnlocked()).toBe(false);629630 await expect(631 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })632 ).resolves.toMatchObject({ ok: true });633 expect(vault.isUnlocked()).toBe(true);634 });635636 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {637 const response = await dispatch({638 type: "FIRE_CANARY_PING",639 payload: {640 webhookUrl: "https://example.com/hook",641 entryTitle: "GitHub",642 trigger: "password_copied",643 },644 });645646 expect(response).toMatchObject({ ok: true });647 expect(fetchMock).toHaveBeenCalledWith(648 "https://example.com/hook",649 expect.objectContaining({650 method: "POST",651 headers: { "Content-Type": "application/json" },652 body: expect.any(String),653 })654 );655 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;656 expect(body).toMatchObject({657 entry: "GitHub",658 trigger: "password_copied",659 source: "peach-extension",660 });661 });662663 it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {664 const payload = {665 webhookUrl: "https://example.com/hook",666 entryTitle: "GitHub",667 trigger: "autofilled" as const,668 };669 const first = await dispatch({ type: "FIRE_CANARY_PING", payload });670 expect(first).toMatchObject({ ok: true });671 expect(fetchMock).toHaveBeenCalledTimes(1);672673 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });674 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });675 expect(fetchMock).toHaveBeenCalledTimes(1); 676 });677678 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {679 await dispatch({680 type: "FIRE_CANARY_PING",681 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },682 });683 await dispatch({684 type: "FIRE_CANARY_PING",685 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },686 });687 expect(fetchMock).toHaveBeenCalledTimes(2);688 });689690 it("LV-3: test pings are exempt from the debounce", async () => {691 const payload = {692 webhookUrl: "https://example.com/hook",693 entryTitle: "GitHub",694 trigger: "test" as const,695 };696 await dispatch({ type: "FIRE_CANARY_PING", payload });697 await dispatch({ type: "FIRE_CANARY_PING", payload });698 expect(fetchMock).toHaveBeenCalledTimes(2);699 });700701 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {702 await expect(703 dispatch({704 type: "FIRE_CANARY_PING",705 payload: {706 webhookUrl: "http://example.com/hook",707 entryTitle: "GitHub",708 trigger: "password_copied",709 },710 })711 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });712713 expect(fetchMock).not.toHaveBeenCalled();714 });715716 it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {717 const response = await dispatch({718 type: "SUBMIT_FEEDBACK",719 payload: {720 category: "Bug report",721 message: "the popup flashes",722 version: "2.1.0",723 browser: "Chrome",724 },725 });726727 expect(response).toMatchObject({ ok: true });728 expect(fetchMock).not.toHaveBeenCalled();729 });730731 it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {732 const response = await dispatch({733 type: "SUBMIT_FEEDBACK",734 payload: {735 category: "Bug report",736 message: " ",737 version: "2.1.0",738 browser: "Chrome",739 },740 });741742 expect(response).toMatchObject({ ok: false, error: "message_empty" });743 expect(fetchMock).not.toHaveBeenCalled();744 });745746 it("GET_IDENTITIES when locked returns vault locked error", async () => {747 const vault = await import("./vault");748 await vault.createVault("correct-pass-12345");749 vault.lock();750 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({751 ok: false,752 error: "vault_locked",753 });754 });755756 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {757 const vault = await import("./vault");758 await vault.createVault("correct-pass-12345");759 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });760 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });761 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });762763 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };764 expect(resp.ok).toBe(true);765 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;766 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({767 type: "email",768 useCount: 2,769 });770 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({771 type: "username",772 useCount: 1,773 });774 775 const emailIdx = data.findIndex((d) => d.value === "[email protected]");776 const userIdx = data.findIndex((d) => d.value === "juni_patel");777 expect(emailIdx).toBeLessThan(userIdx);778 });779780 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {781 const vault = await import("./vault");782 await vault.createVault("correct-pass-12345");783 for (let i = 0; i < 3; i++) {784 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });785 }786787 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };788 const data = resp.data as Array<{ value: string; useCount: number }>;789 const hits = data.filter((d) => d.value === "[email protected]");790 expect(hits).toHaveLength(1);791 expect(hits[0]!.useCount).toBe(3);792 });793794 it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {795 const vault = await import("./vault");796 await vault.createVault("correct-pass-12345");797 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;798799 800 alarmsCreate.mockClear();801 await dispatch({ type: "GET_ENTRIES" });802 expect(alarmsCreate).toHaveBeenCalled();803804 805 alarmsCreate.mockClear();806 await dispatch({807 type: "SUBMIT_FEEDBACK",808 payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },809 });810 expect(alarmsCreate).not.toHaveBeenCalled();811 });812813 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {814 const vault = await import("./vault");815 await vault.createVault("correct-pass-12345");816 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });817818 819 storage.peach_license = "PEACH-PRO-TEST-KEY";820 storage.peach_pro_celebrated = true;821822 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });823 expect(res).toMatchObject({ ok: true });824825 826 expect(storage.peach_vault_a).toBeUndefined();827 828 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");829 expect(storage.peach_pro_celebrated).toBe(true);830 });831832 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {833 const messages = await import("./messages");834 const vault = await import("./vault");835836 let resolveGate: () => void = () => {};837 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));838839 let settled = false;840 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });841842 843 844 await new Promise((r) => setTimeout(r, 20));845 expect(settled).toBe(false);846847 848 await vault.createVault("correct-pass-12345");849 resolveGate();850851 await expect(pending).resolves.toMatchObject({ ok: true, data: true });852 });853854 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {855 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });856 expect(res.ok).toBe(false);857 858 const has = await dispatch({ type: "HAS_VAULT" });859 expect((has as { data?: boolean }).data).toBe(false);860 });861862 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {863 const vault = await import("./vault");864 await vault.createVault("correct-pass-12345");865 const e = await vault.addEntry({866 title: "Mail",867 username: "june",868 password: "pw",869 url: "https://mail.test",870 totp: "JBSWY3DPEHPK3PXP",871 });872873 const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });874 expect(res.ok).toBe(true);875 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };876 expect(data.totp).toBeUndefined();877 expect(data.totpCode).toMatch(/^\d{6}$/);878 expect(data.totpRemaining).toBeGreaterThan(0);879 expect(data.totpRemaining).toBeLessThanOrEqual(30);880 });881882 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {883 const vault = await import("./vault");884 await vault.createVault("correct-pass-12345");885 const e = await vault.addEntry({886 title: "Bank",887 username: "june",888 password: "old-pw",889 url: "https://bank.test",890 });891 await vault.updateEntry(e.id, { password: "new-pw" });892893 const fill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });894 expect(fill.ok).toBe(true);895 896 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();897898 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });899 expect(edit.ok).toBe(true);900 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];901 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);902 });903904 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {905 const vault = await import("./vault");906 await vault.createVault("correct-pass-12345");907 const e = await vault.addEntry({908 title: "Mail",909 username: "june",910 password: "pw",911 url: "https://mail.test",912 totp: "JBSWY3DPEHPK3PXP",913 });914915 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });916 expect(res.ok).toBe(true);917 const data = res.data as { totp?: string };918 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");919 });920921 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {922 const vault = await import("./vault");923 await vault.createVault("correct-pass-12345");924925 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });926 expect(res).toMatchObject({ ok: true });927928 expect(storage.peach_vault_a).toBeUndefined();929 expect(storage.peach_license).toBeUndefined();930 expect(storage.peach_pro_celebrated).toBeUndefined();931 });932933 934935 function tabSender(url: string): chrome.runtime.MessageSender {936 return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };937 }938939 it("MV-1: tab request with a matching domain returns the entry", async () => {940 const vault = await import("./vault");941 await vault.createVault("correct-pass-12345");942 const entry = await vault.addEntry({943 title: "GitHub",944 username: "june",945 password: "secret123",946 url: "https://github.com",947 });948949 const res = await dispatch(950 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },951 tabSender("https://github.com/login"),952 );953 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });954 });955956 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {957 const vault = await import("./vault");958 await vault.createVault("correct-pass-12345");959 const entry = await vault.addEntry({960 title: "GitHub",961 username: "june",962 password: "secret123",963 url: "https://github.com",964 });965966 const res = await dispatch(967 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },968 tabSender("https://evil.example.com/phish"),969 );970 971 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });972 });973974 it("MV-1: mismatched domain with explicitSummon:true returns the entry (user-chosen off-domain fill)", async () => {975 const vault = await import("./vault");976 await vault.createVault("correct-pass-12345");977 const entry = await vault.addEntry({978 title: "GitHub",979 username: "june",980 password: "secret123",981 url: "https://github.com",982 });983984 const res = await dispatch(985 {986 type: "GET_ENTRY_FOR_FILL",987 payload: { id: entry.id, explicitSummon: true },988 },989 tabSender("https://evil.example.com/phish"),990 );991 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });992 });993994 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {995 const vault = await import("./vault");996 await vault.createVault("correct-pass-12345");997 const entry = await vault.addEntry({998 title: "GitHub",999 username: "june",1000 password: "secret123",1001 url: "https://github.com",1002 });10031004 const res = await dispatch(1005 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1006 tabSender("not a url"),1007 );1008 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1009 });10101011 10121013 async function seedTwoDomainVault() {1014 const vault = await import("./vault");1015 await vault.createVault("correct-pass-12345");1016 await vault.addEntry({1017 title: "GitHub",1018 username: "june",1019 password: "gh",1020 url: "https://github.com",1021 });1022 await vault.addEntry({1023 title: "Example",1024 username: "june",1025 password: "ex",1026 url: "https://example.com",1027 });1028 }10291030 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1031 await seedTwoDomainVault();10321033 const res = await dispatch({1034 type: "AUTOFILL_QUERY",1035 payload: { domain: "github.com", includeAll: true },1036 });1037 expect(res.ok).toBe(true);1038 const data = res.data as Array<{ title: string }>;1039 1040 expect(data).toHaveLength(1);1041 expect(data[0].title).toBe("GitHub");1042 });10431044 it("MV-2: AUTOFILL_QUERY with includeAll + userGesture returns the vault-wide list", async () => {1045 await seedTwoDomainVault();10461047 const res = await dispatch({1048 type: "AUTOFILL_QUERY",1049 payload: { domain: "github.com", includeAll: true, userGesture: true },1050 });1051 expect(res.ok).toBe(true);1052 const data = res.data as Array<{ title: string }>;1053 expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);1054 });10551056 10571058 async function seedPendingVouched(deviceId: string) {1059 const { addPendingVouchedDevice } = await import(1060 "./pending-vouched-devices"1061 );1062 await addPendingVouchedDevice({1063 deviceId,1064 deviceName: "New Pixel",1065 deviceType: "android",1066 longTermPublicKeyHex: "e".repeat(64),1067 signingPublicKeyHex: "a".repeat(64),1068 wsPort: 51234,1069 pairedAt: 1_700_000_000_020,1070 receivedAt: 1_700_000_000_050,1071 vouchedByDeviceId: "extension-issuer",1072 });1073 }10741075 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1076 const vault = await import("./vault");1077 await vault.createVault("correct-pass-12345");1078 await seedPendingVouched("android-new");1079 const { findPairedDeviceById } = await import("./paired-devices");1080 const { listPendingVouchedDevices } = await import(1081 "./pending-vouched-devices"1082 );10831084 const res = await dispatch({1085 type: "CONFIRM_VOUCHED_DEVICE",1086 payload: { deviceId: "android-new" },1087 });1088 expect(res).toMatchObject({ ok: true });10891090 expect(await findPairedDeviceById("android-new")).toMatchObject({1091 deviceName: "New Pixel",1092 trustSource: "vouched",1093 longTermPublicKeyHex: "e".repeat(64),1094 });1095 expect(await listPendingVouchedDevices()).toHaveLength(0);1096 });10971098 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1099 const vault = await import("./vault");1100 await vault.createVault("correct-pass-12345");1101 await seedPendingVouched("android-new");1102 const { findPairedDeviceById } = await import("./paired-devices");1103 const { listPendingVouchedDevices } = await import(1104 "./pending-vouched-devices"1105 );11061107 const res = await dispatch({1108 type: "REJECT_VOUCHED_DEVICE",1109 payload: { deviceId: "android-new" },1110 });1111 expect(res).toMatchObject({ ok: true });11121113 expect(await findPairedDeviceById("android-new")).toBeNull();1114 expect(await listPendingVouchedDevices()).toHaveLength(0);1115 });11161117 11181119 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1120 const vault = await import("./vault");1121 await vault.createVault("correct-pass-12345");11221123 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1124 expect(res).toMatchObject({ ok: false, error: "password_required" });1125 });11261127 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1128 const vault = await import("./vault");1129 await vault.createVault("correct-pass-12345");11301131 const res = await dispatch({1132 type: "GET_RECOVERY_WORDS",1133 payload: { password: "not-the-password" },1134 });1135 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1136 expect(res.data).toBeUndefined();1137 });11381139 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1140 const vault = await import("./vault");1141 await vault.createVault("correct-pass-12345");11421143 const res = await dispatch({1144 type: "GET_RECOVERY_WORDS",1145 payload: { password: "correct-pass-12345" },1146 });1147 expect(res.ok).toBe(true);1148 expect((res.data as string[]).length).toBe(12);1149 });1150});