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>;78function deferred(): { promise: Promise<void>; resolve: () => void } {9 let resolve!: () => void;10 const promise = new Promise<void>((done) => {11 resolve = done;12 });13 return { promise, resolve };14}15type MessageListener = (16 msg: unknown,17 sender: chrome.runtime.MessageSender,18 sendResponse: (response: MessageResponse) => void19) => boolean;2021describe("background messages", () => {22 let storage: StorageShape;23 let sessionStorage: StorageShape;24 let listener: MessageListener;25 let fetchMock: ReturnType<typeof vi.fn>;2627 beforeEach(async () => {28 vi.resetModules();29 vi.useRealTimers();30 storage = {31 peach_settings: { breachMonitoring: false },32 };33 sessionStorage = {};3435 fetchMock = vi.fn(async () => ({36 ok: true,37 status: 200,38 text: async () => "",39 }));40 vi.stubGlobal("fetch", fetchMock);41 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);4243 const tabs = [{ id: 1 }];44 const chromeMock = {45 runtime: {46 id: "peach-extension",47 getURL: (p: string) => `chrome-extension://peach-extension/${p}`,48 onMessage: {49 addListener: vi.fn((fn: MessageListener) => {50 listener = fn;51 }),52 },53 sendMessage: vi.fn(async () => ({ ok: true })),54 },55 tabs: {56 query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {57 if (typeof callback === "function") {58 callback(tabs);59 return;60 }61 return Promise.resolve(tabs);62 }),63 sendMessage: vi.fn(async () => ({ ok: true })),64 },65 storage: {66 local: {67 get: vi.fn(async (key?: string | string[]) => {68 if (!key) return storage;69 if (typeof key === "string") {70 return { [key]: storage[key] };71 }7273 return key.reduce<Record<string, unknown>>((result, entry) => {74 result[entry] = storage[entry];75 return result;76 }, {});77 }),78 set: vi.fn(async (next: Record<string, unknown>) => {79 Object.assign(storage, next);80 }),81 remove: vi.fn(async (keys: string | string[]) => {82 const list = Array.isArray(keys) ? keys : [keys];83 for (const key of list) {84 delete storage[key];85 }86 }),87 clear: vi.fn(async () => {88 for (const key of Object.keys(storage)) delete storage[key];89 }),90 },91 session: {92 get: vi.fn(async (key?: string | string[]) => {93 if (!key) return sessionStorage;94 if (typeof key === "string") {95 return { [key]: sessionStorage[key] };96 }97 return key.reduce<Record<string, unknown>>((result, entry) => {98 result[entry] = sessionStorage[entry];99 return result;100 }, {});101 }),102 set: vi.fn(async (next: Record<string, unknown>) => {103 Object.assign(sessionStorage, next);104 }),105 remove: vi.fn(async (keys: string | string[]) => {106 const list = Array.isArray(keys) ? keys : [keys];107 for (const key of list) {108 delete sessionStorage[key];109 }110 }),111 clear: vi.fn(async () => {112 for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];113 }),114 },115 sync: {116 get: vi.fn(async (_key?: string | string[]) => ({})),117 set: vi.fn(async (_values: Record<string, unknown>) => undefined),118 remove: vi.fn(async (_keys: string | string[]) => undefined),119 clear: vi.fn(async () => undefined),120 },121 },122 alarms: {123 clear: vi.fn(),124 create: vi.fn(),125 onAlarm: { addListener: vi.fn() },126 },127 idle: {128 setDetectionInterval: vi.fn(),129 onStateChanged: { addListener: vi.fn() },130 },131 scripting: {132 executeScript: vi.fn(async () => []),133 },134 } as unknown as typeof chrome;135136 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;137138 const messages = await import("./messages");139 messages.setupMessageHandler();140 const vault = await import("./vault");141 await vault.ensureSettingsLoaded();142 });143144 async function dispatch(145 message: Message,146 sender: chrome.runtime.MessageSender = {147 id: "peach-extension",148 url: "chrome-extension://peach-extension/popup.html",149 }150 ): Promise<MessageResponse> {151 return await new Promise<MessageResponse>((resolve) => {152 const keepChannelOpen = listener(message, sender, (response) => resolve(response));153 expect(keepChannelOpen).toBe(true);154 });155 }156157 it("PB-26-303: page GET_SETTINGS returns only theme, accent, and its own summon record", async () => {158 storage.peach_settings = {159 theme: "dark",160 accentColor: "peach",161 autoLockMinutes: 5,162 pinEnabled: true,163 manualSummonDomains: {164 "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },165 "other.example": { firstSummonedAt: 4, summonCount: 5, lastSummonedAt: 6 },166 },167 };168 const response = await dispatch(169 { type: "GET_SETTINGS" },170 {171 id: "peach-extension",172 url: "https://login.example.com/form",173 tab: { id: 1 },174 frameId: 0,175 } as chrome.runtime.MessageSender,176 );177178 expect(response).toEqual({179 ok: true,180 data: {181 theme: "dark",182 accentColor: "peach",183 manualSummonDomains: {184 "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },185 },186 },187 });188 await expect(dispatch({ type: "GET_SETTINGS" })).resolves.toMatchObject({189 ok: true,190 data: {191 autoLockMinutes: 5,192 pinEnabled: true,193 manualSummonDomains: {194 "example.com": expect.anything(),195 "other.example": expect.anything(),196 },197 },198 });199 });200201 it("GET_ENTRIES when unlocked returns the entry array", async () => {202 const vault = await import("./vault");203 await vault.createVault("correct-pass-12345");204 await vault.addEntry({205 title: "GitHub",206 username: "june",207 password: "secret123",208 url: "https://github.com",209 });210211 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({212 ok: true,213 data: [214 expect.objectContaining({215 title: "GitHub",216 username: "june",217 }),218 ],219 });220 });221222 it("GET_ENTRIES when locked returns a vault locked error", async () => {223 const vault = await import("./vault");224 await vault.createVault("correct-pass-12345");225 vault.lock();226227 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({228 ok: false,229 error: "vault_locked",230 });231 });232233 it("ADD_ENTRY with a valid payload returns the created entry", async () => {234 const vault = await import("./vault");235 await vault.createVault("correct-pass-12345");236237 const response = await dispatch({238 type: "ADD_ENTRY",239 payload: {240 title: "GitHub",241 username: "june",242 password: "secret123",243 url: "https://github.com",244 },245 });246247 expect(response).toMatchObject({248 ok: true,249 data: expect.objectContaining({250 id: expect.any(String),251 title: "GitHub",252 }),253 });254 expect(vault.getEntries()).toHaveLength(1);255 });256257 it("ADD_ENTRY when locked returns a vault locked error", async () => {258 const vault = await import("./vault");259 await vault.createVault("correct-pass-12345");260 vault.lock();261262 await expect(263 dispatch({264 type: "ADD_ENTRY",265 payload: {266 title: "GitHub",267 username: "june",268 password: "secret123",269 url: "https://github.com",270 },271 })272 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });273 });274275 it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {276 const vault = await import("./vault");277 await vault.createVault("correct-pass-12345");278279 await expect(280 dispatch({281 type: "ADD_ENTRY",282 payload: {283 title: "GitHub",284 username: "june",285 password: "",286 url: "https://github.com",287 },288 })289 ).resolves.toMatchObject({ ok: true });290 });291292 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {293 const vault = await import("./vault");294 await vault.createVault("correct-pass-12345");295 const entry = await vault.addEntry({296 title: "GitHub",297 username: "june",298 password: "secret123",299 url: "https://github.com",300 });301302 await expect(303 dispatch({304 type: "UPDATE_ENTRY",305 payload: { id: entry.id, patch: { password: "new-secret" } },306 })307 ).resolves.toMatchObject({ ok: true });308309 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");310 });311312 it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {313 const vault = await import("./vault");314 await vault.createVault("correct-pass-12345");315 const entry = await vault.addEntry({316 title: "GitHub",317 username: "june",318 password: "secret123",319 url: "https://github.com",320 });321322 await expect(323 dispatch({324 type: "UPDATE_ENTRY",325 payload: { id: entry.id, patch: { password: "" } },326 })327 ).resolves.toMatchObject({ ok: true });328329 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();330 });331332 it("DELETE_ENTRY soft-deletes the target entry", async () => {333 const vault = await import("./vault");334 await vault.createVault("correct-pass-12345");335 const entry = await vault.addEntry({336 title: "GitHub",337 username: "june",338 password: "secret123",339 url: "https://github.com",340 });341342 await expect(343 dispatch({344 type: "DELETE_ENTRY",345 payload: { id: entry.id },346 })347 ).resolves.toMatchObject({ ok: true });348349 expect(vault.getEntries()).toEqual([]);350 expect(vault.getDeletedEntries()).toHaveLength(1);351 });352353 it("DELETE_ENTRY of a missing id returns an error", async () => {354 const vault = await import("./vault");355 await vault.createVault("correct-pass-12345");356357 await expect(358 dispatch({359 type: "DELETE_ENTRY",360 payload: { id: "missing" },361 })362 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });363 });364365 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {366 const vault = await import("./vault");367 await vault.createVault("correct-pass-12345");368 const entry = await vault.addEntry({369 title: "GitHub",370 username: "june",371 password: "secret123",372 url: "https://github.com",373 });374375 const response = await dispatch(376 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },377 { id: "peach-extension", url: "https://github.com/login" },378 );379380 expect(response).toMatchObject({381 ok: true,382 data: expect.objectContaining({383 id: entry.id,384 username: "june",385 password: "secret123",386 }),387 });388 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();389 });390391 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {392 const vault = await import("./vault");393 await vault.createVault("correct-pass-12345");394 vault.lock();395396 await expect(397 dispatch({398 type: "GET_ENTRY_FOR_FILL",399 payload: { id: "missing" },400 })401 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });402 });403404 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {405 const vault = await import("./vault");406 await vault.createVault("correct-pass-12345");407 const generated = await generatePasskeyKeypair(-7);408 const entry = await vault.addEntry({409 type: "login",410 title: "WebAuthn.io",411 passkey: {412 rpId: "webauthn.io",413 rpName: "WebAuthn.io",414 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),415 publicKeyCose: base64urlEncode(generated.publicKeyCose),416 privateKeyJwk: generated.privateKeyJwk,417 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),418 userName: "june",419 userDisplayName: "June",420 algorithm: -7,421 signCount: 0,422 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",423 backupEligible: true,424 backupState: false,425 createdAt: Date.now(),426 },427 });428429 await expect(430 dispatch({431 type: "GET_ENTRY_FOR_FILL",432 payload: { id: entry.id },433 })434 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });435 });436437 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {438 const vault = await import("./vault");439 await vault.createVault("correct-pass-12345");440 await vault.addEntry({441 title: "GitHub",442 username: "june",443 password: "secret123",444 url: "https://github.com",445 totp: "JBSWY3DPEHPK3PXP",446 });447448 const response = await dispatch({449 type: "AUTOFILL_QUERY",450 payload: { domain: "github.com" },451 });452453 expect(response).toMatchObject({454 ok: true,455 data: [456 {457 id: expect.any(String),458 title: "GitHub",459 username: "june",460 url: "https://github.com",461 hasTotp: true,462 },463 ],464 });465 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();466 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();467 });468469 it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {470 const vault = await import("./vault");471 await vault.createVault("correct-pass-12345");472 await vault.addEntry({473 title: "GitHub",474 username: "june",475 password: "secret123",476 url: "https://github.com",477 });478 await vault.addEntry({479 type: "note",480 title: "Secure note",481 notes: "hello",482 });483484 const response = await dispatch({485 type: "AUTOFILL_QUERY",486 payload: { domain: "github.com", includeAll: true },487 });488489 expect(response).toMatchObject({490 ok: true,491 data: [492 {493 id: expect.any(String),494 title: "GitHub",495 username: "june",496 url: "https://github.com",497 hasPasskey: false,498 },499 ],500 });501 const data = response.data as Array<Record<string, unknown>>;502 expect(data).toHaveLength(1);503 504 expect(data[0]).not.toHaveProperty("password");505 expect(data[0]).not.toHaveProperty("totp");506 });507508 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {509 const vault = await import("./vault");510 await vault.createVault("correct-pass-12345");511 await vault.addEntry({512 title: "GitHub",513 username: "june",514 password: "secret123",515 url: "https://github.com",516 totp: "JBSWY3DPEHPK3PXP",517 });518 await vault.addEntry({519 title: "No 2FA",520 username: "person",521 password: "secret123",522 url: "https://github.com",523 });524525 const response = await dispatch({526 type: "AUTOFILL_QUERY",527 payload: { domain: "github.com", kind: "totp" },528 });529530 expect(response).toMatchObject({531 ok: true,532 data: [533 {534 id: expect.any(String),535 title: "GitHub",536 username: "june",537 url: "https://github.com",538 hasTotp: true,539 },540 ],541 });542 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();543 expect(response.data).toHaveLength(1);544 });545546 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {547 const vault = await import("./vault");548 await vault.createVault("correct-pass-12345");549 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });550551 const response = await dispatch({552 type: "RESTORE_ENTRIES",553 payload: {554 mode: "replace",555 entries: [556 {557 id: "restored-1",558 type: "login",559 title: "B",560 username: "june",561 password: "two",562 url: "https://b.test",563 createdAt: 1,564 modifiedAt: 1,565 },566 ],567 },568 });569570 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });571 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);572 });573574 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {575 const vault = await import("./vault");576 await vault.createVault("correct-pass-12345");577578 await expect(579 dispatch({580 type: "RESTORE_ENTRIES",581 payload: { mode: "replace", entries: [] },582 })583 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });584 });585586 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {587 const vault = await import("./vault");588 await vault.createVault("correct-pass-12345");589590 await expect(591 dispatch({592 type: "CHANGE_PASSWORD",593 payload: {594 currentPassword: "correct-pass-12345",595 newPassword: "new-pass-67890",596 },597 })598 ).resolves.toMatchObject({ ok: true });599600 vault.lock();601 expect(await vault.unlock("new-pass-67890")).toBe(true);602603 await expect(604 dispatch({605 type: "CHANGE_PASSWORD",606 payload: {607 currentPassword: "wrong-pass",608 newPassword: "another-pass",609 },610 })611 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });612 });613614 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {615 const vault = await import("./vault");616 await vault.createVault("correct-pass-12345");617618 (619 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>620 ).mockClear();621 (622 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>623 ).mockClear();624625 await expect(626 dispatch({627 type: "UPDATE_SETTINGS",628 payload: { autoLockMinutes: 30 },629 })630 ).resolves.toMatchObject({ ok: true });631632 expect(vault.getSettings().autoLockMinutes).toBe(30);633 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);634 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {635 delayInMinutes: 30,636 });637 });638639 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {640 vi.useFakeTimers();641 const vault = await import("./vault");642 await vault.createVault("correct-pass-12345");643 const entry = await vault.addEntry({644 title: "GitHub",645 username: "june",646 password: "secret123",647 url: "https://github.com",648 });649 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);650651 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });652 await vi.runAllTimersAsync();653 const response = await responsePromise;654655 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });656 expect(vault.getEntryById(entry.id)?.breach).toBe(7);657 breachSpy.mockRestore();658 });659660 it("rejects passkey approval from a different tab than the ceremony", async () => {661 sessionStorage.passkey_pending_301 = {662 requestId: 301,663 type: "get",664 rpId: "example.com",665 rpName: "Example",666 userName: "[email protected]",667 tabId: 7,668 origin: "https://example.com",669 };670671 await expect(672 dispatch(673 { type: "PASSKEY_AUTH_RESPONSE", payload: { requestId: 301, approved: true } },674 { id: "peach-extension", tab: { id: 8 }, url: "https://example.com/login" } as chrome.runtime.MessageSender,675 ),676 ).resolves.toMatchObject({ ok: false, error: "invalid_passkey_approval_sender" });677 });678679 it("messages from foreign or URL-less senders are rejected", async () => {680 const vault = await import("./vault");681 await vault.createVault("correct-pass-12345");682683 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({684 ok: false,685 error: "invalid_sender",686 });687688 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({689 ok: false,690 error: "invalid_sender",691 });692 });693694 it("LOCK and UNLOCK messages update vault state correctly", async () => {695 const vault = await import("./vault");696697 await expect(698 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })699 ).resolves.toMatchObject({ ok: true });700 expect(vault.isUnlocked()).toBe(true);701702 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });703 expect(vault.isUnlocked()).toBe(false);704705 await expect(706 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })707 ).resolves.toMatchObject({ ok: false });708 expect(vault.isUnlocked()).toBe(false);709710 await expect(711 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })712 ).resolves.toMatchObject({ ok: true });713 expect(vault.isUnlocked()).toBe(true);714 });715716 it("does not acknowledge LOCK until session cleanup finishes", async () => {717 await expect(718 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })719 ).resolves.toMatchObject({ ok: true });720721 const removalStarted = deferred();722 const releaseRemoval = deferred();723 const remove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;724 remove.mockImplementation(async (keys: string | string[]) => {725 removalStarted.resolve();726 await releaseRemoval.promise;727 for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key];728 });729730 let responded = false;731 const response = dispatch({ type: "LOCK" }).then((result) => {732 responded = true;733 return result;734 });735 await removalStarted.promise;736737 try {738 await Promise.resolve();739 expect(responded).toBe(false);740 } finally {741 releaseRemoval.resolve();742 }743744 await expect(response).resolves.toMatchObject({ ok: true });745 });746747 it("remembers the last successful unlock method", async () => {748 const vault = await import("./vault");749 const password = "correct-pass-12345";750 const popupSender = {751 id: "peach-extension",752 url: "chrome-extension://peach-extension/src/popup/index.html",753 } as chrome.runtime.MessageSender;754755 await vault.createVault(password);756 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);757 vault.lock();758759 await expect(760 dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),761 ).resolves.toMatchObject({ ok: true });762 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);763764 vault.lock();765 await expect(766 dispatch({ type: "UNLOCK", payload: { password } }, popupSender),767 ).resolves.toMatchObject({ ok: true });768 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);769 });770771 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {772 const response = await dispatch({773 type: "FIRE_CANARY_PING",774 payload: {775 webhookUrl: "https://example.com/hook",776 entryTitle: "GitHub",777 trigger: "password_copied",778 },779 });780781 expect(response).toMatchObject({ ok: true });782 expect(fetchMock).toHaveBeenCalledWith(783 "https://example.com/hook",784 expect.objectContaining({785 method: "POST",786 headers: { "Content-Type": "application/json" },787 body: expect.any(String),788 })789 );790 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;791 expect(body).toMatchObject({792 entry: "GitHub",793 trigger: "password_copied",794 source: "peach-extension",795 });796 });797798 it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {799 const payload = {800 webhookUrl: "https://example.com/hook",801 entryTitle: "GitHub",802 trigger: "autofilled" as const,803 };804 const first = await dispatch({ type: "FIRE_CANARY_PING", payload });805 expect(first).toMatchObject({ ok: true });806 expect(fetchMock).toHaveBeenCalledTimes(1);807808 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });809 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });810 expect(fetchMock).toHaveBeenCalledTimes(1); 811 });812813 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {814 await dispatch({815 type: "FIRE_CANARY_PING",816 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },817 });818 await dispatch({819 type: "FIRE_CANARY_PING",820 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },821 });822 expect(fetchMock).toHaveBeenCalledTimes(2);823 });824825 it("LV-3: test pings are exempt from the debounce", async () => {826 const payload = {827 webhookUrl: "https://example.com/hook",828 entryTitle: "GitHub",829 trigger: "test" as const,830 };831 await dispatch({ type: "FIRE_CANARY_PING", payload });832 await dispatch({ type: "FIRE_CANARY_PING", payload });833 expect(fetchMock).toHaveBeenCalledTimes(2);834 });835836 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {837 await expect(838 dispatch({839 type: "FIRE_CANARY_PING",840 payload: {841 webhookUrl: "http://example.com/hook",842 entryTitle: "GitHub",843 trigger: "password_copied",844 },845 })846 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });847848 expect(fetchMock).not.toHaveBeenCalled();849 });850851 it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {852 for (const webhookUrl of [853 "https://[::ffff:192.168.0.1]/hook",854 "https://[::ffff:c0a8:1]/hook",855 "https://[::ffff:10.0.0.1]/hook",856 "https://[::ffff:127.0.0.1]/hook",857 "https://[::ffff:169.254.1.1]/hook",858 "https://[::ffff:172.16.0.1]/hook",859 ]) {860 await expect(861 dispatch({862 type: "FIRE_CANARY_PING",863 payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },864 })865 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });866 }867868 expect(fetchMock).not.toHaveBeenCalled();869 });870871 it("GET_IDENTITIES when locked returns vault locked error", async () => {872 const vault = await import("./vault");873 await vault.createVault("correct-pass-12345");874 vault.lock();875 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({876 ok: false,877 error: "vault_locked",878 });879 });880881 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {882 const vault = await import("./vault");883 await vault.createVault("correct-pass-12345");884 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });885 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });886 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });887888 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };889 expect(resp.ok).toBe(true);890 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;891 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({892 type: "email",893 useCount: 2,894 });895 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({896 type: "username",897 useCount: 1,898 });899 900 const emailIdx = data.findIndex((d) => d.value === "[email protected]");901 const userIdx = data.findIndex((d) => d.value === "juni_patel");902 expect(emailIdx).toBeLessThan(userIdx);903 });904905 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {906 const vault = await import("./vault");907 await vault.createVault("correct-pass-12345");908 for (let i = 0; i < 3; i++) {909 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });910 }911912 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };913 const data = resp.data as Array<{ value: string; useCount: number }>;914 const hits = data.filter((d) => d.value === "[email protected]");915 expect(hits).toHaveLength(1);916 expect(hits[0]!.useCount).toBe(3);917 });918919 it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {920 const vault = await import("./vault");921 await vault.createVault("correct-pass-12345");922 await vault.addEntry({923 title: "Private identity",924 username: "[email protected]",925 password: "pw",926 url: "https://private.example",927 });928929 const senders = [930 {931 id: "peach-extension",932 url: "https://private.example/signup",933 tab: { id: 1 } as chrome.tabs.Tab,934 },935 { id: "peach-extension", url: "https://private.example/signup" },936 ] satisfies chrome.runtime.MessageSender[];937 for (const sender of senders) {938 await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({939 ok: false,940 error: "invalid_sender",941 });942 }943 });944945 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {946 const vault = await import("./vault");947 await vault.createVault("correct-pass-12345");948 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;949950 951 alarmsCreate.mockClear();952 await dispatch({ type: "GET_ENTRIES" });953 expect(alarmsCreate).toHaveBeenCalled();954955 956 alarmsCreate.mockClear();957 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });958 expect(alarmsCreate).not.toHaveBeenCalled();959 });960961 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {962 const vault = await import("./vault");963 await vault.createVault("correct-pass-12345");964 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });965966 967 storage.peach_license = "PEACH-PRO-TEST-KEY";968 storage.peach_pro_celebrated = true;969970 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });971 expect(res).toMatchObject({ ok: true });972973 974 expect(storage.peach_vault_a).toBeUndefined();975 976 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");977 expect(storage.peach_pro_celebrated).toBe(true);978 });979980 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {981 const messages = await import("./messages");982 const vault = await import("./vault");983984 let resolveGate: () => void = () => {};985 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));986987 let settled = false;988 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });989990 991 992 await new Promise((r) => setTimeout(r, 20));993 expect(settled).toBe(false);994995 996 await vault.createVault("correct-pass-12345");997 resolveGate();998999 await expect(pending).resolves.toMatchObject({ ok: true, data: true });1000 });10011002 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {1003 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });1004 expect(res.ok).toBe(false);1005 1006 const has = await dispatch({ type: "HAS_VAULT" });1007 expect((has as { data?: boolean }).data).toBe(false);1008 });10091010 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {1011 const vault = await import("./vault");1012 await vault.createVault("correct-pass-12345");1013 const e = await vault.addEntry({1014 title: "Mail",1015 username: "june",1016 password: "pw",1017 url: "https://mail.test",1018 totp: "JBSWY3DPEHPK3PXP",1019 });10201021 const res = await dispatch(1022 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1023 { id: "peach-extension", url: "https://mail.test/login" },1024 );1025 expect(res.ok).toBe(true);1026 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };1027 expect(data.totp).toBeUndefined();1028 expect(data.totpCode).toMatch(/^\d{6}$/);1029 expect(data.totpRemaining).toBeGreaterThan(0);1030 expect(data.totpRemaining).toBeLessThanOrEqual(30);1031 });10321033 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {1034 const vault = await import("./vault");1035 await vault.createVault("correct-pass-12345");1036 const e = await vault.addEntry({1037 title: "Bank",1038 username: "june",1039 password: "old-pw",1040 url: "https://bank.test",1041 });1042 await vault.updateEntry(e.id, { password: "new-pw" });10431044 const fill = await dispatch(1045 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1046 { id: "peach-extension", url: "https://bank.test/login" },1047 );1048 expect(fill.ok).toBe(true);1049 1050 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();10511052 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1053 expect(edit.ok).toBe(true);1054 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];1055 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);1056 });10571058 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {1059 const vault = await import("./vault");1060 await vault.createVault("correct-pass-12345");1061 const e = await vault.addEntry({1062 title: "Mail",1063 username: "june",1064 password: "pw",1065 url: "https://mail.test",1066 totp: "JBSWY3DPEHPK3PXP",1067 });10681069 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1070 expect(res.ok).toBe(true);1071 const data = res.data as { totp?: string };1072 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");1073 });10741075 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {1076 const vault = await import("./vault");1077 await vault.createVault("correct-pass-12345");10781079 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1080 expect(res).toMatchObject({ ok: true });10811082 expect(storage.peach_vault_a).toBeUndefined();1083 expect(storage.peach_license).toBeUndefined();1084 expect(storage.peach_pro_celebrated).toBeUndefined();1085 });10861087 10881089 function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {1090 return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };1091 }10921093 async function addPasskeyOnlyEntry(title: string, url: string) {1094 const vault = await import("./vault");1095 const generated = await generatePasskeyKeypair(-7);1096 return vault.addEntry({1097 type: "login",1098 title,1099 url,1100 passkey: {1101 rpId: new URL(url).hostname,1102 rpName: title,1103 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1104 publicKeyCose: base64urlEncode(generated.publicKeyCose),1105 privateKeyJwk: generated.privateKeyJwk,1106 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1107 userName: "june",1108 userDisplayName: "June",1109 algorithm: -7,1110 signCount: 0,1111 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1112 backupEligible: true,1113 backupState: false,1114 createdAt: Date.now(),1115 },1116 });1117 }11181119 it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1120 const vault = await import("./vault");1121 await vault.createVault("correct-pass-12345");1122 await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1123 await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });11241125 const result = await dispatch(1126 {1127 type: "AUTOFILL_QUERY",1128 payload: {1129 domain: "github.com",1130 includeAll: true,1131 userGesture: true,1132 explicitSummon: true,1133 },1134 },1135 tabSender("https://evil.example.com/phish"),1136 );11371138 expect(result).toMatchObject({ ok: true });1139 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1140 });11411142 it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1143 const vault = await import("./vault");1144 await vault.createVault("correct-pass-12345");1145 await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1146 await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });11471148 const result = await dispatch(1149 { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1150 tabSender("https://evil.example.com/verify"),1151 );11521153 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1154 });11551156 it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1157 const vault = await import("./vault");1158 const messages = await import("./messages");1159 await vault.createVault("correct-pass-12345");1160 const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1161 const sender = tabSender("https://evil.example.com/phish");1162 const token = messages.createSummonCapability(1, "https://evil.example.com");11631164 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1165 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });11661167 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1168 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1169 const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1170 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1171 });11721173 it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1174 const vault = await import("./vault");1175 const messages = await import("./messages");1176 await vault.createVault("correct-pass-12345");1177 const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1178 const sender = tabSender("https://evil.example.com/login");1179 const token = messages.createSummonCapability(1, "https://evil.example.com");11801181 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1182 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1183 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1184 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });11851186 const offDomainReplay = await dispatch(1187 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1188 tabSender("https://github.com/login"),1189 );1190 expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1191 });11921193 it("PB-26-201: summon capability cannot fill a different entry", async () => {1194 const vault = await import("./vault");1195 const messages = await import("./messages");1196 await vault.createVault("correct-pass-12345");1197 const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1198 const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1199 const sender = tabSender("https://evil.example.com/phish");1200 const token = messages.createSummonCapability(1, "https://evil.example.com");1201 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1202 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1203 expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1204 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1205 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1206 expect(first.id).not.toBe(second.id);1207 });12081209 it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1210 const vault = await import("./vault");1211 const messages = await import("./messages");1212 await vault.createVault("correct-pass-12345");1213 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1214 const token = messages.createSummonCapability(1, "https://evil.example.com");1215 const result = await dispatch(1216 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1217 tabSender("https://evil.example.com/phish"),1218 );1219 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1220 });12211222 it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1223 const vault = await import("./vault");1224 const messages = await import("./messages");1225 await vault.createVault("correct-pass-12345");1226 const entry = await vault.addEntry({1227 title: "Evil",1228 password: "known",1229 url: "https://evil.example.com",1230 });1231 const sender = tabSender("https://evil.example.com/login");1232 const token = messages.createSummonCapability(1, "https://evil.example.com");12331234 const result = await dispatch(1235 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1236 sender,1237 );12381239 expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1240 });12411242 it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1243 vi.useFakeTimers();1244 vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1245 const vault = await import("./vault");1246 const messages = await import("./messages");1247 await vault.createVault("correct-pass-12345");1248 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1249 const sender = tabSender("https://evil.example.com/phish");1250 const token = messages.createSummonCapability(1, "https://evil.example.com");1251 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1252 vi.advanceTimersByTime(30_001);12531254 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1255 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1256 });12571258 it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1259 const vault = await import("./vault");1260 const messages = await import("./messages");1261 await vault.createVault("correct-pass-12345");1262 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1263 const token = messages.createSummonCapability(1, "https://evil.example.com");1264 await dispatch(1265 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1266 tabSender("https://evil.example.com/phish"),1267 );12681269 const result = await dispatch(1270 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1271 tabSender("https://evil.example.com/phish", 2),1272 );1273 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1274 });12751276 it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1277 const vault = await import("./vault");1278 const messages = await import("./messages");1279 await vault.createVault("correct-pass-12345");1280 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1281 const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1282 await dispatch(1283 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1284 tabSender("https://evil.example.com/phish", 1, 3),1285 );12861287 const result = await dispatch(1288 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1289 tabSender("https://evil.example.com/phish", 1, 4),1290 );1291 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1292 });12931294 it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1295 const vault = await import("./vault");1296 const messages = await import("./messages");1297 await vault.createVault("correct-pass-12345");1298 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1299 const token = messages.createSummonCapability(1, "https://evil.example.com");1300 await dispatch(1301 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1302 tabSender("https://evil.example.com/phish"),1303 );13041305 const result = await dispatch(1306 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1307 tabSender("https://other.example.com/phish"),1308 );1309 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1310 });13111312 it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1313 const vault = await import("./vault");1314 const messages = await import("./messages");1315 await vault.createVault("correct-pass-12345");1316 const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1317 const sender = tabSender("https://evil.example.com/phish");1318 const token = messages.createSummonCapability(1, "https://evil.example.com");1319 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);13201321 const missing = await dispatch(1322 { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1323 sender,1324 );1325 const existingReplay = await dispatch(1326 { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1327 sender,1328 );1329 expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1330 expect(existingReplay).toEqual(missing);1331 });13321333 it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1334 const vault = await import("./vault");1335 const messages = await import("./messages");1336 await vault.createVault("correct-pass-12345");1337 await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1338 const sender = tabSender("https://evil.example.com/phish");1339 const token = messages.createSummonCapability(1, "https://evil.example.com");1340 const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1341 const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);13421343 expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1344 expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1345 });13461347 it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1348 const vault = await import("./vault");1349 const messages = await import("./messages");1350 await vault.createVault("correct-pass-12345");1351 const card = await vault.addEntry({1352 type: "card",1353 title: "Personal Visa",1354 cardholderName: "June Peach",1355 cardNumber: "4111111111111111",1356 cvc: "123",1357 expMonth: "12",1358 expYear: "2030",1359 });1360 const sender = tabSender("https://shop.example.com/checkout");1361 const passive = await dispatch(1362 { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1363 sender,1364 );1365 expect(passive).toMatchObject({ ok: true, data: [] });13661367 const token = messages.createSummonCapability(1, "https://shop.example.com");1368 const inventory = await dispatch(1369 { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1370 sender,1371 );1372 expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1373 expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");13741375 const fill = await dispatch(1376 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1377 sender,1378 );1379 expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1380 const replay = await dispatch(1381 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1382 sender,1383 );1384 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1385 });13861387 it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1388 const result = await dispatch(1389 { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1390 tabSender("https://example.com/login"),1391 );13921393 expect(result).toMatchObject({ ok: true });1394 expect(sessionStorage.peach_open_entry).toBe("entry-123");1395 expect(sessionStorage.peach_inline_unlock).toBeUndefined();1396 });13971398 it("MV-1: tab request with a matching domain returns the entry", async () => {1399 const vault = await import("./vault");1400 await vault.createVault("correct-pass-12345");1401 const entry = await vault.addEntry({1402 title: "GitHub",1403 username: "june",1404 password: "secret123",1405 url: "https://github.com",1406 });14071408 const res = await dispatch(1409 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1410 tabSender("https://github.com/login"),1411 );1412 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1413 });14141415 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1416 const vault = await import("./vault");1417 await vault.createVault("correct-pass-12345");1418 const entry = await vault.addEntry({1419 title: "GitHub",1420 username: "june",1421 password: "secret123",1422 url: "https://github.com",1423 });14241425 const res = await dispatch(1426 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1427 tabSender("https://evil.example.com/phish"),1428 );1429 1430 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1431 });14321433 it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1434 const vault = await import("./vault");1435 await vault.createVault("correct-pass-12345");1436 const entry = await vault.addEntry({1437 title: "GitHub",1438 username: "june",1439 password: "secret123",1440 url: "https://github.com",1441 });14421443 const res = await dispatch(1444 {1445 type: "GET_ENTRY_FOR_FILL",1446 payload: { id: entry.id, explicitSummon: true },1447 },1448 tabSender("https://evil.example.com/phish"),1449 );1450 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1451 });14521453 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1454 const vault = await import("./vault");1455 await vault.createVault("correct-pass-12345");1456 const entry = await vault.addEntry({1457 title: "GitHub",1458 username: "june",1459 password: "secret123",1460 url: "https://github.com",1461 });14621463 const res = await dispatch(1464 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1465 tabSender("not a url"),1466 );1467 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1468 });14691470 14711472 async function seedTwoDomainVault() {1473 const vault = await import("./vault");1474 await vault.createVault("correct-pass-12345");1475 await vault.addEntry({1476 title: "GitHub",1477 username: "june",1478 password: "gh",1479 url: "https://github.com",1480 });1481 await vault.addEntry({1482 title: "Example",1483 username: "june",1484 password: "ex",1485 url: "https://example.com",1486 });1487 }14881489 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1490 await seedTwoDomainVault();14911492 const res = await dispatch({1493 type: "AUTOFILL_QUERY",1494 payload: { domain: "github.com", includeAll: true },1495 });1496 expect(res.ok).toBe(true);1497 const data = res.data as Array<{ title: string }>;1498 1499 expect(data).toHaveLength(1);1500 expect(data[0].title).toBe("GitHub");1501 });15021503 it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1504 await seedTwoDomainVault();15051506 const res = await dispatch({1507 type: "AUTOFILL_QUERY",1508 payload: { domain: "github.com", includeAll: true, userGesture: true },1509 });1510 expect(res.ok).toBe(true);1511 const data = res.data as Array<{ title: string }>;1512 expect(data.map((d) => d.title)).toEqual(["GitHub"]);1513 });15141515 15161517 async function seedPendingVouched(deviceId: string) {1518 await chrome.storage.local.set({1519 peach_pending_vouched_devices_v1: [{1520 deviceId,1521 deviceName: "New Pixel",1522 deviceType: "android",1523 longTermPublicKeyHex: "e".repeat(64),1524 signingPublicKeyHex: "a".repeat(64),1525 wsPort: 51234,1526 pairedAt: 1_700_000_000_020,1527 receivedAt: 1_700_000_000_050,1528 vouchedByDeviceId: "extension-issuer",1529 }],1530 });1531 }15321533 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1534 const vault = await import("./vault");1535 await vault.createVault("correct-pass-12345");1536 await seedPendingVouched("android-new");1537 const { findPairedDeviceById } = await import("./paired-devices");1538 const { listPendingVouchedDevices } = await import(1539 "./pending-vouched-devices"1540 );15411542 const res = await dispatch({1543 type: "CONFIRM_VOUCHED_DEVICE",1544 payload: { deviceId: "android-new" },1545 });1546 expect(res).toMatchObject({ ok: true });15471548 expect(await findPairedDeviceById("android-new")).toMatchObject({1549 deviceName: "New Pixel",1550 trustSource: "vouched",1551 longTermPublicKeyHex: "e".repeat(64),1552 });1553 expect(await listPendingVouchedDevices()).toHaveLength(0);1554 });15551556 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1557 const vault = await import("./vault");1558 await vault.createVault("correct-pass-12345");1559 await seedPendingVouched("android-relabelled");1560 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1561 await import("./paired-devices");1562 const { listPendingVouchedDevices } = await import(1563 "./pending-vouched-devices"1564 );15651566 1567 1568 await upsertPairedDevice({1569 deviceId: "android-old",1570 deviceName: "New Pixel",1571 deviceType: "android",1572 longTermPublicKeyHex: "e".repeat(64),1573 signingPublicKeyHex: "a".repeat(64),1574 pairedAt: 1_700_000_000_000,1575 trustSource: "vouched",1576 });1577 await revokeDeviceById("android-old", "self");15781579 const res = await dispatch({1580 type: "CONFIRM_VOUCHED_DEVICE",1581 payload: { deviceId: "android-relabelled" },1582 });1583 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1584 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1585 expect(await listPendingVouchedDevices()).toHaveLength(0);1586 });15871588 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1589 const vault = await import("./vault");1590 await vault.createVault("correct-pass-12345");1591 await seedPendingVouched("android-new");1592 const { findPairedDeviceById } = await import("./paired-devices");1593 const { listPendingVouchedDevices } = await import(1594 "./pending-vouched-devices"1595 );15961597 const res = await dispatch({1598 type: "REJECT_VOUCHED_DEVICE",1599 payload: { deviceId: "android-new" },1600 });1601 expect(res).toMatchObject({ ok: true });16021603 expect(await findPairedDeviceById("android-new")).toBeNull();1604 expect(await listPendingVouchedDevices()).toHaveLength(0);1605 });16061607 16081609 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1610 const vault = await import("./vault");1611 await vault.createVault("correct-pass-12345");1612 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1613 await import("./paired-devices");16141615 await upsertPairedDevice({1616 deviceId: "phone-1",1617 deviceName: "June's Phone",1618 deviceType: "android",1619 longTermPublicKeyHex: "a".repeat(64),1620 signingPublicKeyHex: "b".repeat(64),1621 pairedAt: 1_700_000_000_000,1622 trustSource: "direct",1623 });16241625 const res = await dispatch({1626 type: "UNPAIR_DEVICE",1627 payload: { deviceId: "phone-1" },1628 });1629 expect(res).toMatchObject({ ok: true });16301631 1632 expect(await findPairedDeviceById("phone-1")).toBeNull();1633 1634 1635 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1636 const records = await listDeviceRevocations();1637 expect(records).toHaveLength(1);1638 expect(records[0]).toMatchObject({1639 deviceId: "phone-1",1640 longTermPublicKeyHex: "a".repeat(64),1641 revokedByDeviceId: expect.any(String),1642 });1643 });16441645 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1646 const vault = await import("./vault");1647 await vault.createVault("correct-pass-12345");1648 const { listDeviceRevocations } = await import("./paired-devices");16491650 const res = await dispatch({1651 type: "UNPAIR_DEVICE",1652 payload: { deviceId: "never-paired" },1653 });1654 expect(res).toMatchObject({ ok: true });1655 1656 1657 1658 1659 const records = await listDeviceRevocations();1660 expect(records).toHaveLength(1);1661 expect(records[0]).toMatchObject({1662 deviceId: "never-paired",1663 longTermPublicKeyHex: "0".repeat(64),1664 });1665 });16661667 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1668 const res = await dispatch({1669 type: "UNPAIR_DEVICE",1670 payload: { deviceId: "" },1671 });1672 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1673 });16741675 1676 1677 1678 1679 1680 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1681 const vault = await import("./vault");1682 await vault.createVault("correct-pass-12345");1683 const contentScriptSender = {1684 id: "peach-extension",1685 url: "https://evil.example/page",1686 };1687 const extensionPageSender = {1688 id: "peach-extension",1689 url: "chrome-extension://peach-extension/popup.html",1690 };1691 const completion = { requestId: 1, rpId: "evil.example" };16921693 1694 await expect(1695 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1696 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });16971698 1699 1700 await expect(1701 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1702 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1703 });17041705 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1706 const vault = await import("./vault");1707 await vault.createVault("correct-pass-12345");1708 const extensionPageSender = {1709 id: "peach-extension",1710 url: "chrome-extension://peach-extension/popup.html",1711 };1712 const completion = {1713 requestId: 123,1714 rpId: "example.com",1715 rpName: "Example",1716 userName: "june",1717 userHandle: "BBBB",1718 credentialId: "cred-1",1719 publicKeyCose: "c3BraQ",1720 algorithm: -7 as const,1721 };17221723 1724 await expect(1725 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1726 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });17271728 1729 sessionStorage.passkey_pending_firefox = {1730 requestId: 123,1731 type: "create",1732 rpId: "example.com",1733 rpName: "Example",1734 userName: "june",1735 browser: "firefox",1736 challenge: "AAAA",1737 userHandle: "BBBB",1738 };17391740 1741 await expect(1742 dispatch(1743 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1744 extensionPageSender1745 )1746 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });17471748 1749 await expect(1750 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1751 ).resolves.toMatchObject({ ok: true });1752 const entries = await dispatch({ type: "GET_ENTRIES" });1753 expect(entries).toMatchObject({ ok: true });1754 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1755 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1756 });17571758 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1759 await expect(1760 dispatch(1761 {1762 type: "INITIATE_FIREFOX_PASSKEY_CREATE",1763 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1764 },1765 { id: "peach-extension", url: "https://evil.example/page" }1766 )1767 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1768 });17691770 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1771 1772 1773 1774 1775 const vault = await import("./vault");1776 await vault.createVault("correct-pass-12345");1777 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1778 "./paired-devices"1779 );17801781 await upsertPairedDevice({1782 deviceId: "phone-1",1783 deviceName: "June's Phone",1784 deviceType: "android",1785 longTermPublicKeyHex: "a".repeat(64),1786 signingPublicKeyHex: "b".repeat(64),1787 pairedAt: 1_700_000_000_000,1788 trustSource: "direct",1789 });17901791 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1792 await revokeDeviceById("phone-1", "self");1793 1794 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1795 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1796 });17971798 17991800 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1801 const vault = await import("./vault");1802 await vault.createVault("correct-pass-12345");18031804 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1805 expect(res).toMatchObject({ ok: false, error: "password_required" });1806 });18071808 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1809 const vault = await import("./vault");1810 await vault.createVault("correct-pass-12345");18111812 const res = await dispatch({1813 type: "GET_RECOVERY_WORDS",1814 payload: { password: "not-the-password" },1815 });1816 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1817 expect(res.data).toBeUndefined();1818 });18191820 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1821 const vault = await import("./vault");1822 await vault.createVault("correct-pass-12345");18231824 const res = await dispatch({1825 type: "GET_RECOVERY_WORDS",1826 payload: { password: "correct-pass-12345" },1827 });1828 expect(res.ok).toBe(true);1829 expect((res.data as string[]).length).toBe(12);1830 });18311832 it("PB-26-311: cleartext export requires a fresh master-password proof", async () => {1833 const vault = await import("./vault");1834 await vault.createVault("correct-pass-12345");18351836 await expect(1837 dispatch({ type: "EXPORT_ENTRIES" as never, payload: {} }),1838 ).resolves.toMatchObject({ ok: false, error: "password_required" });1839 await expect(1840 dispatch({1841 type: "EXPORT_ENTRIES" as never,1842 payload: { password: "not-the-password" },1843 }),1844 ).resolves.toMatchObject({ ok: false, error: "wrong_password" });1845 await expect(1846 dispatch({1847 type: "EXPORT_ENTRIES" as never,1848 payload: { password: "correct-pass-12345" },1849 }),1850 ).resolves.toMatchObject({ ok: true, data: [] });1851 });18521853 it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1854 const vault = await import("./vault");1855 const password = "correct-pass-12345";1856 const popupSender = {1857 id: "peach-extension",1858 url: "chrome-extension://peach-extension/src/popup/index.html",1859 } as chrome.runtime.MessageSender;18601861 await vault.createVault(password);1862 await vault.setupPinUnlock(password, "1234");1863 await vault.lock();18641865 await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1866 ok: true,1867 data: { configured: true, available: true, attemptsRemaining: 3 },1868 });1869 });1870});