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("GET_IDENTITIES when locked returns vault locked error", async () => {717 const vault = await import("./vault");718 await vault.createVault("correct-pass-12345");719 vault.lock();720 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({721 ok: false,722 error: "vault_locked",723 });724 });725726 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {727 const vault = await import("./vault");728 await vault.createVault("correct-pass-12345");729 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });730 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });731 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });732733 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };734 expect(resp.ok).toBe(true);735 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;736 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({737 type: "email",738 useCount: 2,739 });740 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({741 type: "username",742 useCount: 1,743 });744 745 const emailIdx = data.findIndex((d) => d.value === "[email protected]");746 const userIdx = data.findIndex((d) => d.value === "juni_patel");747 expect(emailIdx).toBeLessThan(userIdx);748 });749750 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {751 const vault = await import("./vault");752 await vault.createVault("correct-pass-12345");753 for (let i = 0; i < 3; i++) {754 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });755 }756757 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };758 const data = resp.data as Array<{ value: string; useCount: number }>;759 const hits = data.filter((d) => d.value === "[email protected]");760 expect(hits).toHaveLength(1);761 expect(hits[0]!.useCount).toBe(3);762 });763764 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {765 const vault = await import("./vault");766 await vault.createVault("correct-pass-12345");767 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;768769 770 alarmsCreate.mockClear();771 await dispatch({ type: "GET_ENTRIES" });772 expect(alarmsCreate).toHaveBeenCalled();773774 775 alarmsCreate.mockClear();776 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });777 expect(alarmsCreate).not.toHaveBeenCalled();778 });779780 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {781 const vault = await import("./vault");782 await vault.createVault("correct-pass-12345");783 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });784785 786 storage.peach_license = "PEACH-PRO-TEST-KEY";787 storage.peach_pro_celebrated = true;788789 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });790 expect(res).toMatchObject({ ok: true });791792 793 expect(storage.peach_vault_a).toBeUndefined();794 795 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");796 expect(storage.peach_pro_celebrated).toBe(true);797 });798799 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {800 const messages = await import("./messages");801 const vault = await import("./vault");802803 let resolveGate: () => void = () => {};804 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));805806 let settled = false;807 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });808809 810 811 await new Promise((r) => setTimeout(r, 20));812 expect(settled).toBe(false);813814 815 await vault.createVault("correct-pass-12345");816 resolveGate();817818 await expect(pending).resolves.toMatchObject({ ok: true, data: true });819 });820821 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {822 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });823 expect(res.ok).toBe(false);824 825 const has = await dispatch({ type: "HAS_VAULT" });826 expect((has as { data?: boolean }).data).toBe(false);827 });828829 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {830 const vault = await import("./vault");831 await vault.createVault("correct-pass-12345");832 const e = await vault.addEntry({833 title: "Mail",834 username: "june",835 password: "pw",836 url: "https://mail.test",837 totp: "JBSWY3DPEHPK3PXP",838 });839840 const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });841 expect(res.ok).toBe(true);842 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };843 expect(data.totp).toBeUndefined();844 expect(data.totpCode).toMatch(/^\d{6}$/);845 expect(data.totpRemaining).toBeGreaterThan(0);846 expect(data.totpRemaining).toBeLessThanOrEqual(30);847 });848849 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {850 const vault = await import("./vault");851 await vault.createVault("correct-pass-12345");852 const e = await vault.addEntry({853 title: "Bank",854 username: "june",855 password: "old-pw",856 url: "https://bank.test",857 });858 await vault.updateEntry(e.id, { password: "new-pw" });859860 const fill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });861 expect(fill.ok).toBe(true);862 863 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();864865 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });866 expect(edit.ok).toBe(true);867 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];868 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);869 });870871 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {872 const vault = await import("./vault");873 await vault.createVault("correct-pass-12345");874 const e = await vault.addEntry({875 title: "Mail",876 username: "june",877 password: "pw",878 url: "https://mail.test",879 totp: "JBSWY3DPEHPK3PXP",880 });881882 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });883 expect(res.ok).toBe(true);884 const data = res.data as { totp?: string };885 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");886 });887888 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {889 const vault = await import("./vault");890 await vault.createVault("correct-pass-12345");891892 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });893 expect(res).toMatchObject({ ok: true });894895 expect(storage.peach_vault_a).toBeUndefined();896 expect(storage.peach_license).toBeUndefined();897 expect(storage.peach_pro_celebrated).toBeUndefined();898 });899900 901902 function tabSender(url: string): chrome.runtime.MessageSender {903 return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };904 }905906 it("MV-1: tab request with a matching domain returns the entry", async () => {907 const vault = await import("./vault");908 await vault.createVault("correct-pass-12345");909 const entry = await vault.addEntry({910 title: "GitHub",911 username: "june",912 password: "secret123",913 url: "https://github.com",914 });915916 const res = await dispatch(917 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },918 tabSender("https://github.com/login"),919 );920 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });921 });922923 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {924 const vault = await import("./vault");925 await vault.createVault("correct-pass-12345");926 const entry = await vault.addEntry({927 title: "GitHub",928 username: "june",929 password: "secret123",930 url: "https://github.com",931 });932933 const res = await dispatch(934 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },935 tabSender("https://evil.example.com/phish"),936 );937 938 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });939 });940941 it("MV-1: mismatched domain with explicitSummon:true returns the entry (user-chosen off-domain fill)", async () => {942 const vault = await import("./vault");943 await vault.createVault("correct-pass-12345");944 const entry = await vault.addEntry({945 title: "GitHub",946 username: "june",947 password: "secret123",948 url: "https://github.com",949 });950951 const res = await dispatch(952 {953 type: "GET_ENTRY_FOR_FILL",954 payload: { id: entry.id, explicitSummon: true },955 },956 tabSender("https://evil.example.com/phish"),957 );958 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });959 });960961 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {962 const vault = await import("./vault");963 await vault.createVault("correct-pass-12345");964 const entry = await vault.addEntry({965 title: "GitHub",966 username: "june",967 password: "secret123",968 url: "https://github.com",969 });970971 const res = await dispatch(972 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },973 tabSender("not a url"),974 );975 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });976 });977978 979980 async function seedTwoDomainVault() {981 const vault = await import("./vault");982 await vault.createVault("correct-pass-12345");983 await vault.addEntry({984 title: "GitHub",985 username: "june",986 password: "gh",987 url: "https://github.com",988 });989 await vault.addEntry({990 title: "Example",991 username: "june",992 password: "ex",993 url: "https://example.com",994 });995 }996997 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {998 await seedTwoDomainVault();9991000 const res = await dispatch({1001 type: "AUTOFILL_QUERY",1002 payload: { domain: "github.com", includeAll: true },1003 });1004 expect(res.ok).toBe(true);1005 const data = res.data as Array<{ title: string }>;1006 1007 expect(data).toHaveLength(1);1008 expect(data[0].title).toBe("GitHub");1009 });10101011 it("MV-2: AUTOFILL_QUERY with includeAll + userGesture returns the vault-wide list", async () => {1012 await seedTwoDomainVault();10131014 const res = await dispatch({1015 type: "AUTOFILL_QUERY",1016 payload: { domain: "github.com", includeAll: true, userGesture: true },1017 });1018 expect(res.ok).toBe(true);1019 const data = res.data as Array<{ title: string }>;1020 expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);1021 });10221023 10241025 async function seedPendingVouched(deviceId: string) {1026 const { addPendingVouchedDevice } = await import(1027 "./pending-vouched-devices"1028 );1029 await addPendingVouchedDevice({1030 deviceId,1031 deviceName: "New Pixel",1032 deviceType: "android",1033 longTermPublicKeyHex: "e".repeat(64),1034 signingPublicKeyHex: "a".repeat(64),1035 wsPort: 51234,1036 pairedAt: 1_700_000_000_020,1037 receivedAt: 1_700_000_000_050,1038 vouchedByDeviceId: "extension-issuer",1039 });1040 }10411042 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1043 const vault = await import("./vault");1044 await vault.createVault("correct-pass-12345");1045 await seedPendingVouched("android-new");1046 const { findPairedDeviceById } = await import("./paired-devices");1047 const { listPendingVouchedDevices } = await import(1048 "./pending-vouched-devices"1049 );10501051 const res = await dispatch({1052 type: "CONFIRM_VOUCHED_DEVICE",1053 payload: { deviceId: "android-new" },1054 });1055 expect(res).toMatchObject({ ok: true });10561057 expect(await findPairedDeviceById("android-new")).toMatchObject({1058 deviceName: "New Pixel",1059 trustSource: "vouched",1060 longTermPublicKeyHex: "e".repeat(64),1061 });1062 expect(await listPendingVouchedDevices()).toHaveLength(0);1063 });10641065 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1066 const vault = await import("./vault");1067 await vault.createVault("correct-pass-12345");1068 await seedPendingVouched("android-new");1069 const { findPairedDeviceById } = await import("./paired-devices");1070 const { listPendingVouchedDevices } = await import(1071 "./pending-vouched-devices"1072 );10731074 const res = await dispatch({1075 type: "REJECT_VOUCHED_DEVICE",1076 payload: { deviceId: "android-new" },1077 });1078 expect(res).toMatchObject({ ok: true });10791080 expect(await findPairedDeviceById("android-new")).toBeNull();1081 expect(await listPendingVouchedDevices()).toHaveLength(0);1082 });10831084 10851086 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1087 const vault = await import("./vault");1088 await vault.createVault("correct-pass-12345");10891090 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1091 expect(res).toMatchObject({ ok: false, error: "password_required" });1092 });10931094 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1095 const vault = await import("./vault");1096 await vault.createVault("correct-pass-12345");10971098 const res = await dispatch({1099 type: "GET_RECOVERY_WORDS",1100 payload: { password: "not-the-password" },1101 });1102 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1103 expect(res.data).toBeUndefined();1104 });11051106 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1107 const vault = await import("./vault");1108 await vault.createVault("correct-pass-12345");11091110 const res = await dispatch({1111 type: "GET_RECOVERY_WORDS",1112 payload: { password: "correct-pass-12345" },1113 });1114 expect(res.ok).toBe(true);1115 expect((res.data as string[]).length).toBe(12);1116 });1117});