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 sync: {108 get: vi.fn(async (_key?: string | string[]) => ({})),109 set: vi.fn(async (_values: Record<string, unknown>) => undefined),110 remove: vi.fn(async (_keys: string | string[]) => undefined),111 clear: vi.fn(async () => undefined),112 },113 },114 alarms: {115 clear: vi.fn(),116 create: vi.fn(),117 onAlarm: { addListener: vi.fn() },118 },119 idle: {120 setDetectionInterval: vi.fn(),121 onStateChanged: { addListener: vi.fn() },122 },123 scripting: {124 executeScript: vi.fn(async () => []),125 },126 } as unknown as typeof chrome;127128 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;129130 const messages = await import("./messages");131 messages.setupMessageHandler();132 const vault = await import("./vault");133 await vault.ensureSettingsLoaded();134 });135136 async function dispatch(137 message: Message,138 sender: chrome.runtime.MessageSender = { id: "peach-extension" }139 ): Promise<MessageResponse> {140 return await new Promise<MessageResponse>((resolve) => {141 const keepChannelOpen = listener(message, sender, (response) => resolve(response));142 expect(keepChannelOpen).toBe(true);143 });144 }145146 it("GET_ENTRIES when unlocked returns the entry array", async () => {147 const vault = await import("./vault");148 await vault.createVault("correct-pass-12345");149 await vault.addEntry({150 title: "GitHub",151 username: "june",152 password: "secret123",153 url: "https://github.com",154 });155156 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({157 ok: true,158 data: [159 expect.objectContaining({160 title: "GitHub",161 username: "june",162 }),163 ],164 });165 });166167 it("GET_ENTRIES when locked returns a vault locked error", async () => {168 const vault = await import("./vault");169 await vault.createVault("correct-pass-12345");170 vault.lock();171172 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({173 ok: false,174 error: "vault_locked",175 });176 });177178 it("ADD_ENTRY with a valid payload returns the created entry", async () => {179 const vault = await import("./vault");180 await vault.createVault("correct-pass-12345");181182 const response = await dispatch({183 type: "ADD_ENTRY",184 payload: {185 title: "GitHub",186 username: "june",187 password: "secret123",188 url: "https://github.com",189 },190 });191192 expect(response).toMatchObject({193 ok: true,194 data: expect.objectContaining({195 id: expect.any(String),196 title: "GitHub",197 }),198 });199 expect(vault.getEntries()).toHaveLength(1);200 });201202 it("ADD_ENTRY when locked returns a vault locked error", async () => {203 const vault = await import("./vault");204 await vault.createVault("correct-pass-12345");205 vault.lock();206207 await expect(208 dispatch({209 type: "ADD_ENTRY",210 payload: {211 title: "GitHub",212 username: "june",213 password: "secret123",214 url: "https://github.com",215 },216 })217 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });218 });219220 it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {221 const vault = await import("./vault");222 await vault.createVault("correct-pass-12345");223224 await expect(225 dispatch({226 type: "ADD_ENTRY",227 payload: {228 title: "GitHub",229 username: "june",230 password: "",231 url: "https://github.com",232 },233 })234 ).resolves.toMatchObject({ ok: true });235 });236237 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {238 const vault = await import("./vault");239 await vault.createVault("correct-pass-12345");240 const entry = await vault.addEntry({241 title: "GitHub",242 username: "june",243 password: "secret123",244 url: "https://github.com",245 });246247 await expect(248 dispatch({249 type: "UPDATE_ENTRY",250 payload: { id: entry.id, patch: { password: "new-secret" } },251 })252 ).resolves.toMatchObject({ ok: true });253254 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");255 });256257 it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {258 const vault = await import("./vault");259 await vault.createVault("correct-pass-12345");260 const entry = await vault.addEntry({261 title: "GitHub",262 username: "june",263 password: "secret123",264 url: "https://github.com",265 });266267 await expect(268 dispatch({269 type: "UPDATE_ENTRY",270 payload: { id: entry.id, patch: { password: "" } },271 })272 ).resolves.toMatchObject({ ok: true });273274 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();275 });276277 it("DELETE_ENTRY soft-deletes the target entry", async () => {278 const vault = await import("./vault");279 await vault.createVault("correct-pass-12345");280 const entry = await vault.addEntry({281 title: "GitHub",282 username: "june",283 password: "secret123",284 url: "https://github.com",285 });286287 await expect(288 dispatch({289 type: "DELETE_ENTRY",290 payload: { id: entry.id },291 })292 ).resolves.toMatchObject({ ok: true });293294 expect(vault.getEntries()).toEqual([]);295 expect(vault.getDeletedEntries()).toHaveLength(1);296 });297298 it("DELETE_ENTRY of a missing id returns an error", async () => {299 const vault = await import("./vault");300 await vault.createVault("correct-pass-12345");301302 await expect(303 dispatch({304 type: "DELETE_ENTRY",305 payload: { id: "missing" },306 })307 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });308 });309310 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {311 const vault = await import("./vault");312 await vault.createVault("correct-pass-12345");313 const entry = await vault.addEntry({314 title: "GitHub",315 username: "june",316 password: "secret123",317 url: "https://github.com",318 });319320 const response = await dispatch({321 type: "GET_ENTRY_FOR_FILL",322 payload: { id: entry.id },323 });324325 expect(response).toMatchObject({326 ok: true,327 data: expect.objectContaining({328 id: entry.id,329 username: "june",330 password: "secret123",331 }),332 });333 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();334 });335336 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {337 const vault = await import("./vault");338 await vault.createVault("correct-pass-12345");339 vault.lock();340341 await expect(342 dispatch({343 type: "GET_ENTRY_FOR_FILL",344 payload: { id: "missing" },345 })346 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });347 });348349 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {350 const vault = await import("./vault");351 await vault.createVault("correct-pass-12345");352 const generated = await generatePasskeyKeypair(-7);353 const entry = await vault.addEntry({354 type: "login",355 title: "WebAuthn.io",356 passkey: {357 rpId: "webauthn.io",358 rpName: "WebAuthn.io",359 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),360 publicKeyCose: base64urlEncode(generated.publicKeyCose),361 privateKeyJwk: generated.privateKeyJwk,362 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),363 userName: "june",364 userDisplayName: "June",365 algorithm: -7,366 signCount: 0,367 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",368 backupEligible: true,369 backupState: false,370 createdAt: Date.now(),371 },372 });373374 await expect(375 dispatch({376 type: "GET_ENTRY_FOR_FILL",377 payload: { id: entry.id },378 })379 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });380 });381382 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {383 const vault = await import("./vault");384 await vault.createVault("correct-pass-12345");385 await vault.addEntry({386 title: "GitHub",387 username: "june",388 password: "secret123",389 url: "https://github.com",390 totp: "JBSWY3DPEHPK3PXP",391 });392393 const response = await dispatch({394 type: "AUTOFILL_QUERY",395 payload: { domain: "github.com" },396 });397398 expect(response).toMatchObject({399 ok: true,400 data: [401 {402 id: expect.any(String),403 title: "GitHub",404 username: "june",405 url: "https://github.com",406 hasTotp: true,407 },408 ],409 });410 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();411 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();412 });413414 it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {415 const vault = await import("./vault");416 await vault.createVault("correct-pass-12345");417 await vault.addEntry({418 title: "GitHub",419 username: "june",420 password: "secret123",421 url: "https://github.com",422 });423 await vault.addEntry({424 type: "note",425 title: "Secure note",426 notes: "hello",427 });428429 const response = await dispatch({430 type: "AUTOFILL_QUERY",431 payload: { domain: "github.com", includeAll: true },432 });433434 expect(response).toMatchObject({435 ok: true,436 data: [437 {438 id: expect.any(String),439 title: "GitHub",440 username: "june",441 url: "https://github.com",442 hasPasskey: false,443 },444 ],445 });446 const data = response.data as Array<Record<string, unknown>>;447 expect(data).toHaveLength(1);448 449 expect(data[0]).not.toHaveProperty("password");450 expect(data[0]).not.toHaveProperty("totp");451 });452453 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {454 const vault = await import("./vault");455 await vault.createVault("correct-pass-12345");456 await vault.addEntry({457 title: "GitHub",458 username: "june",459 password: "secret123",460 url: "https://github.com",461 totp: "JBSWY3DPEHPK3PXP",462 });463 await vault.addEntry({464 title: "No 2FA",465 username: "person",466 password: "secret123",467 url: "https://github.com",468 });469470 const response = await dispatch({471 type: "AUTOFILL_QUERY",472 payload: { domain: "github.com", kind: "totp" },473 });474475 expect(response).toMatchObject({476 ok: true,477 data: [478 {479 id: expect.any(String),480 title: "GitHub",481 username: "june",482 url: "https://github.com",483 hasTotp: true,484 },485 ],486 });487 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();488 expect(response.data).toHaveLength(1);489 });490491 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {492 const vault = await import("./vault");493 await vault.createVault("correct-pass-12345");494 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });495496 const response = await dispatch({497 type: "RESTORE_ENTRIES",498 payload: {499 mode: "replace",500 entries: [501 {502 id: "restored-1",503 type: "login",504 title: "B",505 username: "june",506 password: "two",507 url: "https://b.test",508 createdAt: 1,509 modifiedAt: 1,510 },511 ],512 },513 });514515 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });516 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);517 });518519 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {520 const vault = await import("./vault");521 await vault.createVault("correct-pass-12345");522523 await expect(524 dispatch({525 type: "RESTORE_ENTRIES",526 payload: { mode: "replace", entries: [] },527 })528 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });529 });530531 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {532 const vault = await import("./vault");533 await vault.createVault("correct-pass-12345");534535 await expect(536 dispatch({537 type: "CHANGE_PASSWORD",538 payload: {539 currentPassword: "correct-pass-12345",540 newPassword: "new-pass-67890",541 },542 })543 ).resolves.toMatchObject({ ok: true });544545 vault.lock();546 expect(await vault.unlock("new-pass-67890")).toBe(true);547548 await expect(549 dispatch({550 type: "CHANGE_PASSWORD",551 payload: {552 currentPassword: "wrong-pass",553 newPassword: "another-pass",554 },555 })556 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });557 });558559 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {560 const vault = await import("./vault");561 await vault.createVault("correct-pass-12345");562563 (564 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>565 ).mockClear();566 (567 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>568 ).mockClear();569570 await expect(571 dispatch({572 type: "UPDATE_SETTINGS",573 payload: { autoLockMinutes: 30 },574 })575 ).resolves.toMatchObject({ ok: true });576577 expect(vault.getSettings().autoLockMinutes).toBe(30);578 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);579 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {580 delayInMinutes: 30,581 });582 });583584 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {585 vi.useFakeTimers();586 const vault = await import("./vault");587 await vault.createVault("correct-pass-12345");588 const entry = await vault.addEntry({589 title: "GitHub",590 username: "june",591 password: "secret123",592 url: "https://github.com",593 });594 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);595596 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });597 await vi.runAllTimersAsync();598 const response = await responsePromise;599600 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });601 expect(vault.getEntryById(entry.id)?.breach).toBe(7);602 breachSpy.mockRestore();603 });604605 it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {606 const vault = await import("./vault");607 await vault.createVault("correct-pass-12345");608609 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({610 ok: false,611 error: "invalid_sender",612 });613614 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({615 ok: true,616 data: [],617 });618 });619620 it("LOCK and UNLOCK messages update vault state correctly", async () => {621 const vault = await import("./vault");622623 await expect(624 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })625 ).resolves.toMatchObject({ ok: true });626 expect(vault.isUnlocked()).toBe(true);627628 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });629 expect(vault.isUnlocked()).toBe(false);630631 await expect(632 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })633 ).resolves.toMatchObject({ ok: false });634 expect(vault.isUnlocked()).toBe(false);635636 await expect(637 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })638 ).resolves.toMatchObject({ ok: true });639 expect(vault.isUnlocked()).toBe(true);640 });641642 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {643 const response = await dispatch({644 type: "FIRE_CANARY_PING",645 payload: {646 webhookUrl: "https://example.com/hook",647 entryTitle: "GitHub",648 trigger: "password_copied",649 },650 });651652 expect(response).toMatchObject({ ok: true });653 expect(fetchMock).toHaveBeenCalledWith(654 "https://example.com/hook",655 expect.objectContaining({656 method: "POST",657 headers: { "Content-Type": "application/json" },658 body: expect.any(String),659 })660 );661 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;662 expect(body).toMatchObject({663 entry: "GitHub",664 trigger: "password_copied",665 source: "peach-extension",666 });667 });668669 it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {670 const payload = {671 webhookUrl: "https://example.com/hook",672 entryTitle: "GitHub",673 trigger: "autofilled" as const,674 };675 const first = await dispatch({ type: "FIRE_CANARY_PING", payload });676 expect(first).toMatchObject({ ok: true });677 expect(fetchMock).toHaveBeenCalledTimes(1);678679 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });680 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });681 expect(fetchMock).toHaveBeenCalledTimes(1); 682 });683684 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {685 await dispatch({686 type: "FIRE_CANARY_PING",687 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },688 });689 await dispatch({690 type: "FIRE_CANARY_PING",691 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },692 });693 expect(fetchMock).toHaveBeenCalledTimes(2);694 });695696 it("LV-3: test pings are exempt from the debounce", async () => {697 const payload = {698 webhookUrl: "https://example.com/hook",699 entryTitle: "GitHub",700 trigger: "test" as const,701 };702 await dispatch({ type: "FIRE_CANARY_PING", payload });703 await dispatch({ type: "FIRE_CANARY_PING", payload });704 expect(fetchMock).toHaveBeenCalledTimes(2);705 });706707 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {708 await expect(709 dispatch({710 type: "FIRE_CANARY_PING",711 payload: {712 webhookUrl: "http://example.com/hook",713 entryTitle: "GitHub",714 trigger: "password_copied",715 },716 })717 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });718719 expect(fetchMock).not.toHaveBeenCalled();720 });721722 it("GET_IDENTITIES when locked returns vault locked error", async () => {723 const vault = await import("./vault");724 await vault.createVault("correct-pass-12345");725 vault.lock();726 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({727 ok: false,728 error: "vault_locked",729 });730 });731732 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {733 const vault = await import("./vault");734 await vault.createVault("correct-pass-12345");735 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });736 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });737 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });738739 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };740 expect(resp.ok).toBe(true);741 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;742 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({743 type: "email",744 useCount: 2,745 });746 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({747 type: "username",748 useCount: 1,749 });750 751 const emailIdx = data.findIndex((d) => d.value === "[email protected]");752 const userIdx = data.findIndex((d) => d.value === "juni_patel");753 expect(emailIdx).toBeLessThan(userIdx);754 });755756 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {757 const vault = await import("./vault");758 await vault.createVault("correct-pass-12345");759 for (let i = 0; i < 3; i++) {760 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });761 }762763 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };764 const data = resp.data as Array<{ value: string; useCount: number }>;765 const hits = data.filter((d) => d.value === "[email protected]");766 expect(hits).toHaveLength(1);767 expect(hits[0]!.useCount).toBe(3);768 });769770 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {771 const vault = await import("./vault");772 await vault.createVault("correct-pass-12345");773 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;774775 776 alarmsCreate.mockClear();777 await dispatch({ type: "GET_ENTRIES" });778 expect(alarmsCreate).toHaveBeenCalled();779780 781 alarmsCreate.mockClear();782 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });783 expect(alarmsCreate).not.toHaveBeenCalled();784 });785786 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {787 const vault = await import("./vault");788 await vault.createVault("correct-pass-12345");789 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });790791 792 storage.peach_license = "PEACH-PRO-TEST-KEY";793 storage.peach_pro_celebrated = true;794795 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });796 expect(res).toMatchObject({ ok: true });797798 799 expect(storage.peach_vault_a).toBeUndefined();800 801 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");802 expect(storage.peach_pro_celebrated).toBe(true);803 });804805 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {806 const messages = await import("./messages");807 const vault = await import("./vault");808809 let resolveGate: () => void = () => {};810 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));811812 let settled = false;813 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });814815 816 817 await new Promise((r) => setTimeout(r, 20));818 expect(settled).toBe(false);819820 821 await vault.createVault("correct-pass-12345");822 resolveGate();823824 await expect(pending).resolves.toMatchObject({ ok: true, data: true });825 });826827 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {828 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });829 expect(res.ok).toBe(false);830 831 const has = await dispatch({ type: "HAS_VAULT" });832 expect((has as { data?: boolean }).data).toBe(false);833 });834835 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {836 const vault = await import("./vault");837 await vault.createVault("correct-pass-12345");838 const e = await vault.addEntry({839 title: "Mail",840 username: "june",841 password: "pw",842 url: "https://mail.test",843 totp: "JBSWY3DPEHPK3PXP",844 });845846 const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });847 expect(res.ok).toBe(true);848 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };849 expect(data.totp).toBeUndefined();850 expect(data.totpCode).toMatch(/^\d{6}$/);851 expect(data.totpRemaining).toBeGreaterThan(0);852 expect(data.totpRemaining).toBeLessThanOrEqual(30);853 });854855 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {856 const vault = await import("./vault");857 await vault.createVault("correct-pass-12345");858 const e = await vault.addEntry({859 title: "Bank",860 username: "june",861 password: "old-pw",862 url: "https://bank.test",863 });864 await vault.updateEntry(e.id, { password: "new-pw" });865866 const fill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });867 expect(fill.ok).toBe(true);868 869 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();870871 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });872 expect(edit.ok).toBe(true);873 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];874 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);875 });876877 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {878 const vault = await import("./vault");879 await vault.createVault("correct-pass-12345");880 const e = await vault.addEntry({881 title: "Mail",882 username: "june",883 password: "pw",884 url: "https://mail.test",885 totp: "JBSWY3DPEHPK3PXP",886 });887888 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });889 expect(res.ok).toBe(true);890 const data = res.data as { totp?: string };891 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");892 });893894 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {895 const vault = await import("./vault");896 await vault.createVault("correct-pass-12345");897898 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });899 expect(res).toMatchObject({ ok: true });900901 expect(storage.peach_vault_a).toBeUndefined();902 expect(storage.peach_license).toBeUndefined();903 expect(storage.peach_pro_celebrated).toBeUndefined();904 });905906 907908 function tabSender(url: string): chrome.runtime.MessageSender {909 return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };910 }911912 it("MV-1: tab request with a matching domain returns the entry", async () => {913 const vault = await import("./vault");914 await vault.createVault("correct-pass-12345");915 const entry = await vault.addEntry({916 title: "GitHub",917 username: "june",918 password: "secret123",919 url: "https://github.com",920 });921922 const res = await dispatch(923 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },924 tabSender("https://github.com/login"),925 );926 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });927 });928929 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {930 const vault = await import("./vault");931 await vault.createVault("correct-pass-12345");932 const entry = await vault.addEntry({933 title: "GitHub",934 username: "june",935 password: "secret123",936 url: "https://github.com",937 });938939 const res = await dispatch(940 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },941 tabSender("https://evil.example.com/phish"),942 );943 944 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });945 });946947 it("MV-1: mismatched domain with explicitSummon:true returns the entry (user-chosen off-domain fill)", async () => {948 const vault = await import("./vault");949 await vault.createVault("correct-pass-12345");950 const entry = await vault.addEntry({951 title: "GitHub",952 username: "june",953 password: "secret123",954 url: "https://github.com",955 });956957 const res = await dispatch(958 {959 type: "GET_ENTRY_FOR_FILL",960 payload: { id: entry.id, explicitSummon: true },961 },962 tabSender("https://evil.example.com/phish"),963 );964 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });965 });966967 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {968 const vault = await import("./vault");969 await vault.createVault("correct-pass-12345");970 const entry = await vault.addEntry({971 title: "GitHub",972 username: "june",973 password: "secret123",974 url: "https://github.com",975 });976977 const res = await dispatch(978 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },979 tabSender("not a url"),980 );981 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });982 });983984 985986 async function seedTwoDomainVault() {987 const vault = await import("./vault");988 await vault.createVault("correct-pass-12345");989 await vault.addEntry({990 title: "GitHub",991 username: "june",992 password: "gh",993 url: "https://github.com",994 });995 await vault.addEntry({996 title: "Example",997 username: "june",998 password: "ex",999 url: "https://example.com",1000 });1001 }10021003 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1004 await seedTwoDomainVault();10051006 const res = await dispatch({1007 type: "AUTOFILL_QUERY",1008 payload: { domain: "github.com", includeAll: true },1009 });1010 expect(res.ok).toBe(true);1011 const data = res.data as Array<{ title: string }>;1012 1013 expect(data).toHaveLength(1);1014 expect(data[0].title).toBe("GitHub");1015 });10161017 it("MV-2: AUTOFILL_QUERY with includeAll + userGesture returns the vault-wide list", async () => {1018 await seedTwoDomainVault();10191020 const res = await dispatch({1021 type: "AUTOFILL_QUERY",1022 payload: { domain: "github.com", includeAll: true, userGesture: true },1023 });1024 expect(res.ok).toBe(true);1025 const data = res.data as Array<{ title: string }>;1026 expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);1027 });10281029 10301031 async function seedPendingVouched(deviceId: string) {1032 const { addPendingVouchedDevice } = await import(1033 "./pending-vouched-devices"1034 );1035 await addPendingVouchedDevice({1036 deviceId,1037 deviceName: "New Pixel",1038 deviceType: "android",1039 longTermPublicKeyHex: "e".repeat(64),1040 signingPublicKeyHex: "a".repeat(64),1041 wsPort: 51234,1042 pairedAt: 1_700_000_000_020,1043 receivedAt: 1_700_000_000_050,1044 vouchedByDeviceId: "extension-issuer",1045 });1046 }10471048 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1049 const vault = await import("./vault");1050 await vault.createVault("correct-pass-12345");1051 await seedPendingVouched("android-new");1052 const { findPairedDeviceById } = await import("./paired-devices");1053 const { listPendingVouchedDevices } = await import(1054 "./pending-vouched-devices"1055 );10561057 const res = await dispatch({1058 type: "CONFIRM_VOUCHED_DEVICE",1059 payload: { deviceId: "android-new" },1060 });1061 expect(res).toMatchObject({ ok: true });10621063 expect(await findPairedDeviceById("android-new")).toMatchObject({1064 deviceName: "New Pixel",1065 trustSource: "vouched",1066 longTermPublicKeyHex: "e".repeat(64),1067 });1068 expect(await listPendingVouchedDevices()).toHaveLength(0);1069 });10701071 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1072 const vault = await import("./vault");1073 await vault.createVault("correct-pass-12345");1074 await seedPendingVouched("android-relabelled");1075 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1076 await import("./paired-devices");1077 const { listPendingVouchedDevices } = await import(1078 "./pending-vouched-devices"1079 );10801081 1082 1083 await upsertPairedDevice({1084 deviceId: "android-old",1085 deviceName: "New Pixel",1086 deviceType: "android",1087 longTermPublicKeyHex: "e".repeat(64),1088 signingPublicKeyHex: "a".repeat(64),1089 pairedAt: 1_700_000_000_000,1090 trustSource: "vouched",1091 });1092 await revokeDeviceById("android-old", "self");10931094 const res = await dispatch({1095 type: "CONFIRM_VOUCHED_DEVICE",1096 payload: { deviceId: "android-relabelled" },1097 });1098 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1099 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1100 expect(await listPendingVouchedDevices()).toHaveLength(0);1101 });11021103 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1104 const vault = await import("./vault");1105 await vault.createVault("correct-pass-12345");1106 await seedPendingVouched("android-new");1107 const { findPairedDeviceById } = await import("./paired-devices");1108 const { listPendingVouchedDevices } = await import(1109 "./pending-vouched-devices"1110 );11111112 const res = await dispatch({1113 type: "REJECT_VOUCHED_DEVICE",1114 payload: { deviceId: "android-new" },1115 });1116 expect(res).toMatchObject({ ok: true });11171118 expect(await findPairedDeviceById("android-new")).toBeNull();1119 expect(await listPendingVouchedDevices()).toHaveLength(0);1120 });11211122 11231124 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1125 const vault = await import("./vault");1126 await vault.createVault("correct-pass-12345");1127 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1128 await import("./paired-devices");11291130 await upsertPairedDevice({1131 deviceId: "phone-1",1132 deviceName: "June's Phone",1133 deviceType: "android",1134 longTermPublicKeyHex: "a".repeat(64),1135 signingPublicKeyHex: "b".repeat(64),1136 pairedAt: 1_700_000_000_000,1137 trustSource: "direct",1138 });11391140 const res = await dispatch({1141 type: "UNPAIR_DEVICE",1142 payload: { deviceId: "phone-1" },1143 });1144 expect(res).toMatchObject({ ok: true });11451146 1147 expect(await findPairedDeviceById("phone-1")).toBeNull();1148 1149 1150 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1151 const records = await listDeviceRevocations();1152 expect(records).toHaveLength(1);1153 expect(records[0]).toMatchObject({1154 deviceId: "phone-1",1155 longTermPublicKeyHex: "a".repeat(64),1156 revokedByDeviceId: expect.any(String),1157 });1158 });11591160 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1161 const vault = await import("./vault");1162 await vault.createVault("correct-pass-12345");1163 const { listDeviceRevocations } = await import("./paired-devices");11641165 const res = await dispatch({1166 type: "UNPAIR_DEVICE",1167 payload: { deviceId: "never-paired" },1168 });1169 expect(res).toMatchObject({ ok: true });1170 1171 1172 1173 1174 const records = await listDeviceRevocations();1175 expect(records).toHaveLength(1);1176 expect(records[0]).toMatchObject({1177 deviceId: "never-paired",1178 longTermPublicKeyHex: "0".repeat(64),1179 });1180 });11811182 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1183 const res = await dispatch({1184 type: "UNPAIR_DEVICE",1185 payload: { deviceId: "" },1186 });1187 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1188 });11891190 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1191 1192 1193 1194 1195 const vault = await import("./vault");1196 await vault.createVault("correct-pass-12345");1197 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1198 "./paired-devices"1199 );12001201 await upsertPairedDevice({1202 deviceId: "phone-1",1203 deviceName: "June's Phone",1204 deviceType: "android",1205 longTermPublicKeyHex: "a".repeat(64),1206 signingPublicKeyHex: "b".repeat(64),1207 pairedAt: 1_700_000_000_000,1208 trustSource: "direct",1209 });12101211 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1212 await revokeDeviceById("phone-1", "self");1213 1214 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1215 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1216 });12171218 12191220 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1221 const vault = await import("./vault");1222 await vault.createVault("correct-pass-12345");12231224 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1225 expect(res).toMatchObject({ ok: false, error: "password_required" });1226 });12271228 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1229 const vault = await import("./vault");1230 await vault.createVault("correct-pass-12345");12311232 const res = await dispatch({1233 type: "GET_RECOVERY_WORDS",1234 payload: { password: "not-the-password" },1235 });1236 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1237 expect(res.data).toBeUndefined();1238 });12391240 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1241 const vault = await import("./vault");1242 await vault.createVault("correct-pass-12345");12431244 const res = await dispatch({1245 type: "GET_RECOVERY_WORDS",1246 payload: { password: "correct-pass-12345" },1247 });1248 expect(res.ok).toBe(true);1249 expect((res.data as string[]).length).toBe(12);1250 });1251});