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("GET_ENTRIES when unlocked returns the entry array", async () => {158 const vault = await import("./vault");159 await vault.createVault("correct-pass-12345");160 await vault.addEntry({161 title: "GitHub",162 username: "june",163 password: "secret123",164 url: "https://github.com",165 });166167 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({168 ok: true,169 data: [170 expect.objectContaining({171 title: "GitHub",172 username: "june",173 }),174 ],175 });176 });177178 it("GET_ENTRIES when locked returns a vault locked error", async () => {179 const vault = await import("./vault");180 await vault.createVault("correct-pass-12345");181 vault.lock();182183 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({184 ok: false,185 error: "vault_locked",186 });187 });188189 it("ADD_ENTRY with a valid payload returns the created entry", async () => {190 const vault = await import("./vault");191 await vault.createVault("correct-pass-12345");192193 const response = await dispatch({194 type: "ADD_ENTRY",195 payload: {196 title: "GitHub",197 username: "june",198 password: "secret123",199 url: "https://github.com",200 },201 });202203 expect(response).toMatchObject({204 ok: true,205 data: expect.objectContaining({206 id: expect.any(String),207 title: "GitHub",208 }),209 });210 expect(vault.getEntries()).toHaveLength(1);211 });212213 it("ADD_ENTRY when locked returns a vault locked error", async () => {214 const vault = await import("./vault");215 await vault.createVault("correct-pass-12345");216 vault.lock();217218 await expect(219 dispatch({220 type: "ADD_ENTRY",221 payload: {222 title: "GitHub",223 username: "june",224 password: "secret123",225 url: "https://github.com",226 },227 })228 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });229 });230231 it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {232 const vault = await import("./vault");233 await vault.createVault("correct-pass-12345");234235 await expect(236 dispatch({237 type: "ADD_ENTRY",238 payload: {239 title: "GitHub",240 username: "june",241 password: "",242 url: "https://github.com",243 },244 })245 ).resolves.toMatchObject({ ok: true });246 });247248 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {249 const vault = await import("./vault");250 await vault.createVault("correct-pass-12345");251 const entry = await vault.addEntry({252 title: "GitHub",253 username: "june",254 password: "secret123",255 url: "https://github.com",256 });257258 await expect(259 dispatch({260 type: "UPDATE_ENTRY",261 payload: { id: entry.id, patch: { password: "new-secret" } },262 })263 ).resolves.toMatchObject({ ok: true });264265 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");266 });267268 it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {269 const vault = await import("./vault");270 await vault.createVault("correct-pass-12345");271 const entry = await vault.addEntry({272 title: "GitHub",273 username: "june",274 password: "secret123",275 url: "https://github.com",276 });277278 await expect(279 dispatch({280 type: "UPDATE_ENTRY",281 payload: { id: entry.id, patch: { password: "" } },282 })283 ).resolves.toMatchObject({ ok: true });284285 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();286 });287288 it("DELETE_ENTRY soft-deletes the target entry", async () => {289 const vault = await import("./vault");290 await vault.createVault("correct-pass-12345");291 const entry = await vault.addEntry({292 title: "GitHub",293 username: "june",294 password: "secret123",295 url: "https://github.com",296 });297298 await expect(299 dispatch({300 type: "DELETE_ENTRY",301 payload: { id: entry.id },302 })303 ).resolves.toMatchObject({ ok: true });304305 expect(vault.getEntries()).toEqual([]);306 expect(vault.getDeletedEntries()).toHaveLength(1);307 });308309 it("DELETE_ENTRY of a missing id returns an error", async () => {310 const vault = await import("./vault");311 await vault.createVault("correct-pass-12345");312313 await expect(314 dispatch({315 type: "DELETE_ENTRY",316 payload: { id: "missing" },317 })318 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });319 });320321 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {322 const vault = await import("./vault");323 await vault.createVault("correct-pass-12345");324 const entry = await vault.addEntry({325 title: "GitHub",326 username: "june",327 password: "secret123",328 url: "https://github.com",329 });330331 const response = await dispatch(332 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },333 { id: "peach-extension", url: "https://github.com/login" },334 );335336 expect(response).toMatchObject({337 ok: true,338 data: expect.objectContaining({339 id: entry.id,340 username: "june",341 password: "secret123",342 }),343 });344 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();345 });346347 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {348 const vault = await import("./vault");349 await vault.createVault("correct-pass-12345");350 vault.lock();351352 await expect(353 dispatch({354 type: "GET_ENTRY_FOR_FILL",355 payload: { id: "missing" },356 })357 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });358 });359360 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {361 const vault = await import("./vault");362 await vault.createVault("correct-pass-12345");363 const generated = await generatePasskeyKeypair(-7);364 const entry = await vault.addEntry({365 type: "login",366 title: "WebAuthn.io",367 passkey: {368 rpId: "webauthn.io",369 rpName: "WebAuthn.io",370 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),371 publicKeyCose: base64urlEncode(generated.publicKeyCose),372 privateKeyJwk: generated.privateKeyJwk,373 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),374 userName: "june",375 userDisplayName: "June",376 algorithm: -7,377 signCount: 0,378 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",379 backupEligible: true,380 backupState: false,381 createdAt: Date.now(),382 },383 });384385 await expect(386 dispatch({387 type: "GET_ENTRY_FOR_FILL",388 payload: { id: entry.id },389 })390 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });391 });392393 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {394 const vault = await import("./vault");395 await vault.createVault("correct-pass-12345");396 await vault.addEntry({397 title: "GitHub",398 username: "june",399 password: "secret123",400 url: "https://github.com",401 totp: "JBSWY3DPEHPK3PXP",402 });403404 const response = await dispatch({405 type: "AUTOFILL_QUERY",406 payload: { domain: "github.com" },407 });408409 expect(response).toMatchObject({410 ok: true,411 data: [412 {413 id: expect.any(String),414 title: "GitHub",415 username: "june",416 url: "https://github.com",417 hasTotp: true,418 },419 ],420 });421 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();422 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();423 });424425 it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {426 const vault = await import("./vault");427 await vault.createVault("correct-pass-12345");428 await vault.addEntry({429 title: "GitHub",430 username: "june",431 password: "secret123",432 url: "https://github.com",433 });434 await vault.addEntry({435 type: "note",436 title: "Secure note",437 notes: "hello",438 });439440 const response = await dispatch({441 type: "AUTOFILL_QUERY",442 payload: { domain: "github.com", includeAll: true },443 });444445 expect(response).toMatchObject({446 ok: true,447 data: [448 {449 id: expect.any(String),450 title: "GitHub",451 username: "june",452 url: "https://github.com",453 hasPasskey: false,454 },455 ],456 });457 const data = response.data as Array<Record<string, unknown>>;458 expect(data).toHaveLength(1);459 460 expect(data[0]).not.toHaveProperty("password");461 expect(data[0]).not.toHaveProperty("totp");462 });463464 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {465 const vault = await import("./vault");466 await vault.createVault("correct-pass-12345");467 await vault.addEntry({468 title: "GitHub",469 username: "june",470 password: "secret123",471 url: "https://github.com",472 totp: "JBSWY3DPEHPK3PXP",473 });474 await vault.addEntry({475 title: "No 2FA",476 username: "person",477 password: "secret123",478 url: "https://github.com",479 });480481 const response = await dispatch({482 type: "AUTOFILL_QUERY",483 payload: { domain: "github.com", kind: "totp" },484 });485486 expect(response).toMatchObject({487 ok: true,488 data: [489 {490 id: expect.any(String),491 title: "GitHub",492 username: "june",493 url: "https://github.com",494 hasTotp: true,495 },496 ],497 });498 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();499 expect(response.data).toHaveLength(1);500 });501502 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {503 const vault = await import("./vault");504 await vault.createVault("correct-pass-12345");505 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });506507 const response = await dispatch({508 type: "RESTORE_ENTRIES",509 payload: {510 mode: "replace",511 entries: [512 {513 id: "restored-1",514 type: "login",515 title: "B",516 username: "june",517 password: "two",518 url: "https://b.test",519 createdAt: 1,520 modifiedAt: 1,521 },522 ],523 },524 });525526 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });527 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);528 });529530 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {531 const vault = await import("./vault");532 await vault.createVault("correct-pass-12345");533534 await expect(535 dispatch({536 type: "RESTORE_ENTRIES",537 payload: { mode: "replace", entries: [] },538 })539 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });540 });541542 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {543 const vault = await import("./vault");544 await vault.createVault("correct-pass-12345");545546 await expect(547 dispatch({548 type: "CHANGE_PASSWORD",549 payload: {550 currentPassword: "correct-pass-12345",551 newPassword: "new-pass-67890",552 },553 })554 ).resolves.toMatchObject({ ok: true });555556 vault.lock();557 expect(await vault.unlock("new-pass-67890")).toBe(true);558559 await expect(560 dispatch({561 type: "CHANGE_PASSWORD",562 payload: {563 currentPassword: "wrong-pass",564 newPassword: "another-pass",565 },566 })567 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });568 });569570 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {571 const vault = await import("./vault");572 await vault.createVault("correct-pass-12345");573574 (575 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>576 ).mockClear();577 (578 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>579 ).mockClear();580581 await expect(582 dispatch({583 type: "UPDATE_SETTINGS",584 payload: { autoLockMinutes: 30 },585 })586 ).resolves.toMatchObject({ ok: true });587588 expect(vault.getSettings().autoLockMinutes).toBe(30);589 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);590 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {591 delayInMinutes: 30,592 });593 });594595 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {596 vi.useFakeTimers();597 const vault = await import("./vault");598 await vault.createVault("correct-pass-12345");599 const entry = await vault.addEntry({600 title: "GitHub",601 username: "june",602 password: "secret123",603 url: "https://github.com",604 });605 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);606607 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });608 await vi.runAllTimersAsync();609 const response = await responsePromise;610611 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });612 expect(vault.getEntryById(entry.id)?.breach).toBe(7);613 breachSpy.mockRestore();614 });615616 it("messages from foreign or URL-less senders are rejected", async () => {617 const vault = await import("./vault");618 await vault.createVault("correct-pass-12345");619620 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({621 ok: false,622 error: "invalid_sender",623 });624625 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({626 ok: false,627 error: "invalid_sender",628 });629 });630631 it("LOCK and UNLOCK messages update vault state correctly", async () => {632 const vault = await import("./vault");633634 await expect(635 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })636 ).resolves.toMatchObject({ ok: true });637 expect(vault.isUnlocked()).toBe(true);638639 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });640 expect(vault.isUnlocked()).toBe(false);641642 await expect(643 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })644 ).resolves.toMatchObject({ ok: false });645 expect(vault.isUnlocked()).toBe(false);646647 await expect(648 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })649 ).resolves.toMatchObject({ ok: true });650 expect(vault.isUnlocked()).toBe(true);651 });652653 it("does not acknowledge LOCK until session cleanup finishes", async () => {654 await expect(655 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })656 ).resolves.toMatchObject({ ok: true });657658 const removalStarted = deferred();659 const releaseRemoval = deferred();660 const remove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;661 remove.mockImplementation(async (keys: string | string[]) => {662 removalStarted.resolve();663 await releaseRemoval.promise;664 for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key];665 });666667 let responded = false;668 const response = dispatch({ type: "LOCK" }).then((result) => {669 responded = true;670 return result;671 });672 await removalStarted.promise;673674 try {675 await Promise.resolve();676 expect(responded).toBe(false);677 } finally {678 releaseRemoval.resolve();679 }680681 await expect(response).resolves.toMatchObject({ ok: true });682 });683684 it("remembers the last successful unlock method", async () => {685 const vault = await import("./vault");686 const password = "correct-pass-12345";687 const popupSender = {688 id: "peach-extension",689 url: "chrome-extension://peach-extension/src/popup/index.html",690 } as chrome.runtime.MessageSender;691692 await vault.createVault(password);693 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);694 vault.lock();695696 await expect(697 dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),698 ).resolves.toMatchObject({ ok: true });699 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);700701 vault.lock();702 await expect(703 dispatch({ type: "UNLOCK", payload: { password } }, popupSender),704 ).resolves.toMatchObject({ ok: true });705 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);706 });707708 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {709 const response = await dispatch({710 type: "FIRE_CANARY_PING",711 payload: {712 webhookUrl: "https://example.com/hook",713 entryTitle: "GitHub",714 trigger: "password_copied",715 },716 });717718 expect(response).toMatchObject({ ok: true });719 expect(fetchMock).toHaveBeenCalledWith(720 "https://example.com/hook",721 expect.objectContaining({722 method: "POST",723 headers: { "Content-Type": "application/json" },724 body: expect.any(String),725 })726 );727 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;728 expect(body).toMatchObject({729 entry: "GitHub",730 trigger: "password_copied",731 source: "peach-extension",732 });733 });734735 it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {736 const payload = {737 webhookUrl: "https://example.com/hook",738 entryTitle: "GitHub",739 trigger: "autofilled" as const,740 };741 const first = await dispatch({ type: "FIRE_CANARY_PING", payload });742 expect(first).toMatchObject({ ok: true });743 expect(fetchMock).toHaveBeenCalledTimes(1);744745 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });746 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });747 expect(fetchMock).toHaveBeenCalledTimes(1); 748 });749750 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {751 await dispatch({752 type: "FIRE_CANARY_PING",753 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },754 });755 await dispatch({756 type: "FIRE_CANARY_PING",757 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },758 });759 expect(fetchMock).toHaveBeenCalledTimes(2);760 });761762 it("LV-3: test pings are exempt from the debounce", async () => {763 const payload = {764 webhookUrl: "https://example.com/hook",765 entryTitle: "GitHub",766 trigger: "test" as const,767 };768 await dispatch({ type: "FIRE_CANARY_PING", payload });769 await dispatch({ type: "FIRE_CANARY_PING", payload });770 expect(fetchMock).toHaveBeenCalledTimes(2);771 });772773 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {774 await expect(775 dispatch({776 type: "FIRE_CANARY_PING",777 payload: {778 webhookUrl: "http://example.com/hook",779 entryTitle: "GitHub",780 trigger: "password_copied",781 },782 })783 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });784785 expect(fetchMock).not.toHaveBeenCalled();786 });787788 it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {789 for (const webhookUrl of [790 "https://[::ffff:192.168.0.1]/hook",791 "https://[::ffff:c0a8:1]/hook",792 "https://[::ffff:10.0.0.1]/hook",793 "https://[::ffff:127.0.0.1]/hook",794 "https://[::ffff:169.254.1.1]/hook",795 "https://[::ffff:172.16.0.1]/hook",796 ]) {797 await expect(798 dispatch({799 type: "FIRE_CANARY_PING",800 payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },801 })802 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });803 }804805 expect(fetchMock).not.toHaveBeenCalled();806 });807808 it("GET_IDENTITIES when locked returns vault locked error", async () => {809 const vault = await import("./vault");810 await vault.createVault("correct-pass-12345");811 vault.lock();812 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({813 ok: false,814 error: "vault_locked",815 });816 });817818 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {819 const vault = await import("./vault");820 await vault.createVault("correct-pass-12345");821 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });822 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });823 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });824825 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };826 expect(resp.ok).toBe(true);827 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;828 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({829 type: "email",830 useCount: 2,831 });832 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({833 type: "username",834 useCount: 1,835 });836 837 const emailIdx = data.findIndex((d) => d.value === "[email protected]");838 const userIdx = data.findIndex((d) => d.value === "juni_patel");839 expect(emailIdx).toBeLessThan(userIdx);840 });841842 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {843 const vault = await import("./vault");844 await vault.createVault("correct-pass-12345");845 for (let i = 0; i < 3; i++) {846 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });847 }848849 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };850 const data = resp.data as Array<{ value: string; useCount: number }>;851 const hits = data.filter((d) => d.value === "[email protected]");852 expect(hits).toHaveLength(1);853 expect(hits[0]!.useCount).toBe(3);854 });855856 it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {857 const vault = await import("./vault");858 await vault.createVault("correct-pass-12345");859 await vault.addEntry({860 title: "Private identity",861 username: "[email protected]",862 password: "pw",863 url: "https://private.example",864 });865866 const senders = [867 {868 id: "peach-extension",869 url: "https://private.example/signup",870 tab: { id: 1 } as chrome.tabs.Tab,871 },872 { id: "peach-extension", url: "https://private.example/signup" },873 ] satisfies chrome.runtime.MessageSender[];874 for (const sender of senders) {875 await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({876 ok: false,877 error: "invalid_sender",878 });879 }880 });881882 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {883 const vault = await import("./vault");884 await vault.createVault("correct-pass-12345");885 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;886887 888 alarmsCreate.mockClear();889 await dispatch({ type: "GET_ENTRIES" });890 expect(alarmsCreate).toHaveBeenCalled();891892 893 alarmsCreate.mockClear();894 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });895 expect(alarmsCreate).not.toHaveBeenCalled();896 });897898 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {899 const vault = await import("./vault");900 await vault.createVault("correct-pass-12345");901 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });902903 904 storage.peach_license = "PEACH-PRO-TEST-KEY";905 storage.peach_pro_celebrated = true;906907 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });908 expect(res).toMatchObject({ ok: true });909910 911 expect(storage.peach_vault_a).toBeUndefined();912 913 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");914 expect(storage.peach_pro_celebrated).toBe(true);915 });916917 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {918 const messages = await import("./messages");919 const vault = await import("./vault");920921 let resolveGate: () => void = () => {};922 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));923924 let settled = false;925 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });926927 928 929 await new Promise((r) => setTimeout(r, 20));930 expect(settled).toBe(false);931932 933 await vault.createVault("correct-pass-12345");934 resolveGate();935936 await expect(pending).resolves.toMatchObject({ ok: true, data: true });937 });938939 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {940 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });941 expect(res.ok).toBe(false);942 943 const has = await dispatch({ type: "HAS_VAULT" });944 expect((has as { data?: boolean }).data).toBe(false);945 });946947 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {948 const vault = await import("./vault");949 await vault.createVault("correct-pass-12345");950 const e = await vault.addEntry({951 title: "Mail",952 username: "june",953 password: "pw",954 url: "https://mail.test",955 totp: "JBSWY3DPEHPK3PXP",956 });957958 const res = await dispatch(959 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },960 { id: "peach-extension", url: "https://mail.test/login" },961 );962 expect(res.ok).toBe(true);963 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };964 expect(data.totp).toBeUndefined();965 expect(data.totpCode).toMatch(/^\d{6}$/);966 expect(data.totpRemaining).toBeGreaterThan(0);967 expect(data.totpRemaining).toBeLessThanOrEqual(30);968 });969970 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {971 const vault = await import("./vault");972 await vault.createVault("correct-pass-12345");973 const e = await vault.addEntry({974 title: "Bank",975 username: "june",976 password: "old-pw",977 url: "https://bank.test",978 });979 await vault.updateEntry(e.id, { password: "new-pw" });980981 const fill = await dispatch(982 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },983 { id: "peach-extension", url: "https://bank.test/login" },984 );985 expect(fill.ok).toBe(true);986 987 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();988989 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });990 expect(edit.ok).toBe(true);991 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];992 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);993 });994995 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {996 const vault = await import("./vault");997 await vault.createVault("correct-pass-12345");998 const e = await vault.addEntry({999 title: "Mail",1000 username: "june",1001 password: "pw",1002 url: "https://mail.test",1003 totp: "JBSWY3DPEHPK3PXP",1004 });10051006 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1007 expect(res.ok).toBe(true);1008 const data = res.data as { totp?: string };1009 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");1010 });10111012 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {1013 const vault = await import("./vault");1014 await vault.createVault("correct-pass-12345");10151016 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1017 expect(res).toMatchObject({ ok: true });10181019 expect(storage.peach_vault_a).toBeUndefined();1020 expect(storage.peach_license).toBeUndefined();1021 expect(storage.peach_pro_celebrated).toBeUndefined();1022 });10231024 10251026 function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {1027 return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };1028 }10291030 async function addPasskeyOnlyEntry(title: string, url: string) {1031 const vault = await import("./vault");1032 const generated = await generatePasskeyKeypair(-7);1033 return vault.addEntry({1034 type: "login",1035 title,1036 url,1037 passkey: {1038 rpId: new URL(url).hostname,1039 rpName: title,1040 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1041 publicKeyCose: base64urlEncode(generated.publicKeyCose),1042 privateKeyJwk: generated.privateKeyJwk,1043 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1044 userName: "june",1045 userDisplayName: "June",1046 algorithm: -7,1047 signCount: 0,1048 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1049 backupEligible: true,1050 backupState: false,1051 createdAt: Date.now(),1052 },1053 });1054 }10551056 it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1057 const vault = await import("./vault");1058 await vault.createVault("correct-pass-12345");1059 await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1060 await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });10611062 const result = await dispatch(1063 {1064 type: "AUTOFILL_QUERY",1065 payload: {1066 domain: "github.com",1067 includeAll: true,1068 userGesture: true,1069 explicitSummon: true,1070 },1071 },1072 tabSender("https://evil.example.com/phish"),1073 );10741075 expect(result).toMatchObject({ ok: true });1076 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1077 });10781079 it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1080 const vault = await import("./vault");1081 await vault.createVault("correct-pass-12345");1082 await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1083 await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });10841085 const result = await dispatch(1086 { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1087 tabSender("https://evil.example.com/verify"),1088 );10891090 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1091 });10921093 it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1094 const vault = await import("./vault");1095 const messages = await import("./messages");1096 await vault.createVault("correct-pass-12345");1097 const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1098 const sender = tabSender("https://evil.example.com/phish");1099 const token = messages.createSummonCapability(1, "https://evil.example.com");11001101 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1102 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });11031104 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1105 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1106 const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1107 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1108 });11091110 it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1111 const vault = await import("./vault");1112 const messages = await import("./messages");1113 await vault.createVault("correct-pass-12345");1114 const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1115 const sender = tabSender("https://evil.example.com/login");1116 const token = messages.createSummonCapability(1, "https://evil.example.com");11171118 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1119 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1120 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1121 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });11221123 const offDomainReplay = await dispatch(1124 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1125 tabSender("https://github.com/login"),1126 );1127 expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1128 });11291130 it("PB-26-201: summon capability cannot fill a different entry", async () => {1131 const vault = await import("./vault");1132 const messages = await import("./messages");1133 await vault.createVault("correct-pass-12345");1134 const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1135 const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1136 const sender = tabSender("https://evil.example.com/phish");1137 const token = messages.createSummonCapability(1, "https://evil.example.com");1138 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1139 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1140 expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1141 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1142 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1143 expect(first.id).not.toBe(second.id);1144 });11451146 it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1147 const vault = await import("./vault");1148 const messages = await import("./messages");1149 await vault.createVault("correct-pass-12345");1150 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1151 const token = messages.createSummonCapability(1, "https://evil.example.com");1152 const result = await dispatch(1153 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1154 tabSender("https://evil.example.com/phish"),1155 );1156 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1157 });11581159 it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1160 const vault = await import("./vault");1161 const messages = await import("./messages");1162 await vault.createVault("correct-pass-12345");1163 const entry = await vault.addEntry({1164 title: "Evil",1165 password: "known",1166 url: "https://evil.example.com",1167 });1168 const sender = tabSender("https://evil.example.com/login");1169 const token = messages.createSummonCapability(1, "https://evil.example.com");11701171 const result = await dispatch(1172 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1173 sender,1174 );11751176 expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1177 });11781179 it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1180 vi.useFakeTimers();1181 vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1182 const vault = await import("./vault");1183 const messages = await import("./messages");1184 await vault.createVault("correct-pass-12345");1185 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1186 const sender = tabSender("https://evil.example.com/phish");1187 const token = messages.createSummonCapability(1, "https://evil.example.com");1188 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1189 vi.advanceTimersByTime(30_001);11901191 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1192 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1193 });11941195 it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1196 const vault = await import("./vault");1197 const messages = await import("./messages");1198 await vault.createVault("correct-pass-12345");1199 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1200 const token = messages.createSummonCapability(1, "https://evil.example.com");1201 await dispatch(1202 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1203 tabSender("https://evil.example.com/phish"),1204 );12051206 const result = await dispatch(1207 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1208 tabSender("https://evil.example.com/phish", 2),1209 );1210 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1211 });12121213 it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1214 const vault = await import("./vault");1215 const messages = await import("./messages");1216 await vault.createVault("correct-pass-12345");1217 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1218 const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1219 await dispatch(1220 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1221 tabSender("https://evil.example.com/phish", 1, 3),1222 );12231224 const result = await dispatch(1225 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1226 tabSender("https://evil.example.com/phish", 1, 4),1227 );1228 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1229 });12301231 it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1232 const vault = await import("./vault");1233 const messages = await import("./messages");1234 await vault.createVault("correct-pass-12345");1235 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1236 const token = messages.createSummonCapability(1, "https://evil.example.com");1237 await dispatch(1238 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1239 tabSender("https://evil.example.com/phish"),1240 );12411242 const result = await dispatch(1243 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1244 tabSender("https://other.example.com/phish"),1245 );1246 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1247 });12481249 it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1250 const vault = await import("./vault");1251 const messages = await import("./messages");1252 await vault.createVault("correct-pass-12345");1253 const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1254 const sender = tabSender("https://evil.example.com/phish");1255 const token = messages.createSummonCapability(1, "https://evil.example.com");1256 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12571258 const missing = await dispatch(1259 { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1260 sender,1261 );1262 const existingReplay = await dispatch(1263 { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1264 sender,1265 );1266 expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1267 expect(existingReplay).toEqual(missing);1268 });12691270 it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1271 const vault = await import("./vault");1272 const messages = await import("./messages");1273 await vault.createVault("correct-pass-12345");1274 await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1275 const sender = tabSender("https://evil.example.com/phish");1276 const token = messages.createSummonCapability(1, "https://evil.example.com");1277 const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1278 const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12791280 expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1281 expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1282 });12831284 it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1285 const vault = await import("./vault");1286 const messages = await import("./messages");1287 await vault.createVault("correct-pass-12345");1288 const card = await vault.addEntry({1289 type: "card",1290 title: "Personal Visa",1291 cardholderName: "June Peach",1292 cardNumber: "4111111111111111",1293 cvc: "123",1294 expMonth: "12",1295 expYear: "2030",1296 });1297 const sender = tabSender("https://shop.example.com/checkout");1298 const passive = await dispatch(1299 { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1300 sender,1301 );1302 expect(passive).toMatchObject({ ok: true, data: [] });13031304 const token = messages.createSummonCapability(1, "https://shop.example.com");1305 const inventory = await dispatch(1306 { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1307 sender,1308 );1309 expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1310 expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");13111312 const fill = await dispatch(1313 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1314 sender,1315 );1316 expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1317 const replay = await dispatch(1318 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1319 sender,1320 );1321 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1322 });13231324 it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1325 const result = await dispatch(1326 { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1327 tabSender("https://example.com/login"),1328 );13291330 expect(result).toMatchObject({ ok: true });1331 expect(sessionStorage.peach_open_entry).toBe("entry-123");1332 expect(sessionStorage.peach_inline_unlock).toBeUndefined();1333 });13341335 it("MV-1: tab request with a matching domain returns the entry", async () => {1336 const vault = await import("./vault");1337 await vault.createVault("correct-pass-12345");1338 const entry = await vault.addEntry({1339 title: "GitHub",1340 username: "june",1341 password: "secret123",1342 url: "https://github.com",1343 });13441345 const res = await dispatch(1346 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1347 tabSender("https://github.com/login"),1348 );1349 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1350 });13511352 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1353 const vault = await import("./vault");1354 await vault.createVault("correct-pass-12345");1355 const entry = await vault.addEntry({1356 title: "GitHub",1357 username: "june",1358 password: "secret123",1359 url: "https://github.com",1360 });13611362 const res = await dispatch(1363 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1364 tabSender("https://evil.example.com/phish"),1365 );1366 1367 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1368 });13691370 it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1371 const vault = await import("./vault");1372 await vault.createVault("correct-pass-12345");1373 const entry = await vault.addEntry({1374 title: "GitHub",1375 username: "june",1376 password: "secret123",1377 url: "https://github.com",1378 });13791380 const res = await dispatch(1381 {1382 type: "GET_ENTRY_FOR_FILL",1383 payload: { id: entry.id, explicitSummon: true },1384 },1385 tabSender("https://evil.example.com/phish"),1386 );1387 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1388 });13891390 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1391 const vault = await import("./vault");1392 await vault.createVault("correct-pass-12345");1393 const entry = await vault.addEntry({1394 title: "GitHub",1395 username: "june",1396 password: "secret123",1397 url: "https://github.com",1398 });13991400 const res = await dispatch(1401 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1402 tabSender("not a url"),1403 );1404 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1405 });14061407 14081409 async function seedTwoDomainVault() {1410 const vault = await import("./vault");1411 await vault.createVault("correct-pass-12345");1412 await vault.addEntry({1413 title: "GitHub",1414 username: "june",1415 password: "gh",1416 url: "https://github.com",1417 });1418 await vault.addEntry({1419 title: "Example",1420 username: "june",1421 password: "ex",1422 url: "https://example.com",1423 });1424 }14251426 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1427 await seedTwoDomainVault();14281429 const res = await dispatch({1430 type: "AUTOFILL_QUERY",1431 payload: { domain: "github.com", includeAll: true },1432 });1433 expect(res.ok).toBe(true);1434 const data = res.data as Array<{ title: string }>;1435 1436 expect(data).toHaveLength(1);1437 expect(data[0].title).toBe("GitHub");1438 });14391440 it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1441 await seedTwoDomainVault();14421443 const res = await dispatch({1444 type: "AUTOFILL_QUERY",1445 payload: { domain: "github.com", includeAll: true, userGesture: true },1446 });1447 expect(res.ok).toBe(true);1448 const data = res.data as Array<{ title: string }>;1449 expect(data.map((d) => d.title)).toEqual(["GitHub"]);1450 });14511452 14531454 async function seedPendingVouched(deviceId: string) {1455 await chrome.storage.local.set({1456 peach_pending_vouched_devices_v1: [{1457 deviceId,1458 deviceName: "New Pixel",1459 deviceType: "android",1460 longTermPublicKeyHex: "e".repeat(64),1461 signingPublicKeyHex: "a".repeat(64),1462 wsPort: 51234,1463 pairedAt: 1_700_000_000_020,1464 receivedAt: 1_700_000_000_050,1465 vouchedByDeviceId: "extension-issuer",1466 }],1467 });1468 }14691470 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1471 const vault = await import("./vault");1472 await vault.createVault("correct-pass-12345");1473 await seedPendingVouched("android-new");1474 const { findPairedDeviceById } = await import("./paired-devices");1475 const { listPendingVouchedDevices } = await import(1476 "./pending-vouched-devices"1477 );14781479 const res = await dispatch({1480 type: "CONFIRM_VOUCHED_DEVICE",1481 payload: { deviceId: "android-new" },1482 });1483 expect(res).toMatchObject({ ok: true });14841485 expect(await findPairedDeviceById("android-new")).toMatchObject({1486 deviceName: "New Pixel",1487 trustSource: "vouched",1488 longTermPublicKeyHex: "e".repeat(64),1489 });1490 expect(await listPendingVouchedDevices()).toHaveLength(0);1491 });14921493 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1494 const vault = await import("./vault");1495 await vault.createVault("correct-pass-12345");1496 await seedPendingVouched("android-relabelled");1497 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1498 await import("./paired-devices");1499 const { listPendingVouchedDevices } = await import(1500 "./pending-vouched-devices"1501 );15021503 1504 1505 await upsertPairedDevice({1506 deviceId: "android-old",1507 deviceName: "New Pixel",1508 deviceType: "android",1509 longTermPublicKeyHex: "e".repeat(64),1510 signingPublicKeyHex: "a".repeat(64),1511 pairedAt: 1_700_000_000_000,1512 trustSource: "vouched",1513 });1514 await revokeDeviceById("android-old", "self");15151516 const res = await dispatch({1517 type: "CONFIRM_VOUCHED_DEVICE",1518 payload: { deviceId: "android-relabelled" },1519 });1520 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1521 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1522 expect(await listPendingVouchedDevices()).toHaveLength(0);1523 });15241525 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1526 const vault = await import("./vault");1527 await vault.createVault("correct-pass-12345");1528 await seedPendingVouched("android-new");1529 const { findPairedDeviceById } = await import("./paired-devices");1530 const { listPendingVouchedDevices } = await import(1531 "./pending-vouched-devices"1532 );15331534 const res = await dispatch({1535 type: "REJECT_VOUCHED_DEVICE",1536 payload: { deviceId: "android-new" },1537 });1538 expect(res).toMatchObject({ ok: true });15391540 expect(await findPairedDeviceById("android-new")).toBeNull();1541 expect(await listPendingVouchedDevices()).toHaveLength(0);1542 });15431544 15451546 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1547 const vault = await import("./vault");1548 await vault.createVault("correct-pass-12345");1549 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1550 await import("./paired-devices");15511552 await upsertPairedDevice({1553 deviceId: "phone-1",1554 deviceName: "June's Phone",1555 deviceType: "android",1556 longTermPublicKeyHex: "a".repeat(64),1557 signingPublicKeyHex: "b".repeat(64),1558 pairedAt: 1_700_000_000_000,1559 trustSource: "direct",1560 });15611562 const res = await dispatch({1563 type: "UNPAIR_DEVICE",1564 payload: { deviceId: "phone-1" },1565 });1566 expect(res).toMatchObject({ ok: true });15671568 1569 expect(await findPairedDeviceById("phone-1")).toBeNull();1570 1571 1572 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1573 const records = await listDeviceRevocations();1574 expect(records).toHaveLength(1);1575 expect(records[0]).toMatchObject({1576 deviceId: "phone-1",1577 longTermPublicKeyHex: "a".repeat(64),1578 revokedByDeviceId: expect.any(String),1579 });1580 });15811582 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1583 const vault = await import("./vault");1584 await vault.createVault("correct-pass-12345");1585 const { listDeviceRevocations } = await import("./paired-devices");15861587 const res = await dispatch({1588 type: "UNPAIR_DEVICE",1589 payload: { deviceId: "never-paired" },1590 });1591 expect(res).toMatchObject({ ok: true });1592 1593 1594 1595 1596 const records = await listDeviceRevocations();1597 expect(records).toHaveLength(1);1598 expect(records[0]).toMatchObject({1599 deviceId: "never-paired",1600 longTermPublicKeyHex: "0".repeat(64),1601 });1602 });16031604 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1605 const res = await dispatch({1606 type: "UNPAIR_DEVICE",1607 payload: { deviceId: "" },1608 });1609 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1610 });16111612 1613 1614 1615 1616 1617 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1618 const vault = await import("./vault");1619 await vault.createVault("correct-pass-12345");1620 const contentScriptSender = {1621 id: "peach-extension",1622 url: "https://evil.example/page",1623 };1624 const extensionPageSender = {1625 id: "peach-extension",1626 url: "chrome-extension://peach-extension/popup.html",1627 };1628 const completion = { requestId: 1, rpId: "evil.example" };16291630 1631 await expect(1632 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1633 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });16341635 1636 1637 await expect(1638 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1639 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1640 });16411642 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1643 const vault = await import("./vault");1644 await vault.createVault("correct-pass-12345");1645 const extensionPageSender = {1646 id: "peach-extension",1647 url: "chrome-extension://peach-extension/popup.html",1648 };1649 const completion = {1650 requestId: 123,1651 rpId: "example.com",1652 rpName: "Example",1653 userName: "june",1654 userHandle: "BBBB",1655 credentialId: "cred-1",1656 publicKeyCose: "c3BraQ",1657 algorithm: -7 as const,1658 };16591660 1661 await expect(1662 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1663 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });16641665 1666 sessionStorage.passkey_pending_firefox = {1667 requestId: 123,1668 type: "create",1669 rpId: "example.com",1670 rpName: "Example",1671 userName: "june",1672 browser: "firefox",1673 challenge: "AAAA",1674 userHandle: "BBBB",1675 };16761677 1678 await expect(1679 dispatch(1680 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1681 extensionPageSender1682 )1683 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });16841685 1686 await expect(1687 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1688 ).resolves.toMatchObject({ ok: true });1689 const entries = await dispatch({ type: "GET_ENTRIES" });1690 expect(entries).toMatchObject({ ok: true });1691 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1692 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1693 });16941695 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1696 await expect(1697 dispatch(1698 {1699 type: "INITIATE_FIREFOX_PASSKEY_CREATE",1700 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1701 },1702 { id: "peach-extension", url: "https://evil.example/page" }1703 )1704 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1705 });17061707 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1708 1709 1710 1711 1712 const vault = await import("./vault");1713 await vault.createVault("correct-pass-12345");1714 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1715 "./paired-devices"1716 );17171718 await upsertPairedDevice({1719 deviceId: "phone-1",1720 deviceName: "June's Phone",1721 deviceType: "android",1722 longTermPublicKeyHex: "a".repeat(64),1723 signingPublicKeyHex: "b".repeat(64),1724 pairedAt: 1_700_000_000_000,1725 trustSource: "direct",1726 });17271728 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1729 await revokeDeviceById("phone-1", "self");1730 1731 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1732 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1733 });17341735 17361737 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1738 const vault = await import("./vault");1739 await vault.createVault("correct-pass-12345");17401741 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1742 expect(res).toMatchObject({ ok: false, error: "password_required" });1743 });17441745 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1746 const vault = await import("./vault");1747 await vault.createVault("correct-pass-12345");17481749 const res = await dispatch({1750 type: "GET_RECOVERY_WORDS",1751 payload: { password: "not-the-password" },1752 });1753 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1754 expect(res.data).toBeUndefined();1755 });17561757 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1758 const vault = await import("./vault");1759 await vault.createVault("correct-pass-12345");17601761 const res = await dispatch({1762 type: "GET_RECOVERY_WORDS",1763 payload: { password: "correct-pass-12345" },1764 });1765 expect(res.ok).toBe(true);1766 expect((res.data as string[]).length).toBe(12);1767 });17681769 it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1770 const vault = await import("./vault");1771 const password = "correct-pass-12345";1772 const popupSender = {1773 id: "peach-extension",1774 url: "chrome-extension://peach-extension/src/popup/index.html",1775 } as chrome.runtime.MessageSender;17761777 await vault.createVault(password);1778 await vault.setupPinUnlock(password, "1234");1779 await vault.lock();17801781 await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1782 ok: true,1783 data: { configured: true, available: true, attemptsRemaining: 3 },1784 });1785 });1786});