15de0ce01d2c

extract generator policy to testable module and add Section 1 + Section 2 tests

time
2026-05-15 18:40 UTC · 5 months ago
machine
koh2
parent
be8ded95ecde
16 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+48 −2src/background/notes.test.ts+259 −0src/background/recovery-words.test.ts+277 −0src/background/sync-info-sheet.test.ts+177 −0src/background/sync-pro-gate.test.ts+169 −0src/background/sync-screen.test.ts+175 −0src/background/vault.ts+16 −7src/content/passkey-toast/passkey-toast.test.ts+263 −0src/popup/codex/codex.test.ts+460 −0src/popup/codex/helpers.ts+6 −0src/popup/components/GeneratorDrawer.test.ts+196 −0src/popup/generator-policy.ts+64 −0src/shared/types.ts+3 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.43.0",4+  "version": "2.44.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.43.0",4+  "version": "2.44.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.43.0",3+  "version": "2.44.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+48 −2
@@ -555,6 +555,52 @@555555         await vault.deleteEntry(id);556556         return { ok: true };557557       }558+      case "GET_NOTES": {559+        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };560+        const notes = vault.getEntries().filter(561+          (e) => e.type === "note" && !e.deletedAt562+        );563+        return { ok: true, data: notes } as MessageResponse;564+      }565+      case "SEARCH_VAULT": {566+        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };567+        const { query } = msg.payload as { query: string };568+        const q = query.toLowerCase();569+        const searchResults = vault.getEntries().filter(570+          (e) =>571+            !e.deletedAt &&572+            (e.title.toLowerCase().includes(q) ||573+              e.notes?.toLowerCase().includes(q) ||574+              e.username?.toLowerCase().includes(q))575+        );576+        return { ok: true, data: searchResults } as MessageResponse;577+      }578+      case "PAIRING_COMPLETED": {579+        const { deviceName, publicKeyHex, deviceIp } = (msg.payload ?? {}) as {580+          deviceName?: string;581+          publicKeyHex?: string;582+          deviceIp?: string;583+        };584+        if (!publicKeyHex) return { ok: false, error: "publicKeyHex required" };585+        const { setHasPaired: setPaired } = await import("./sync-storage");586+        await setPaired();587+        try {588+          await addPairedDevice({589+            deviceId: publicKeyHex,590+            displayName: deviceName ?? "Unknown",591+            x25519PublicKey: publicKeyHex,592+            pairedAt: Date.now(),593+            lastSeenAt: Date.now(),594+            lastKnownIp: deviceIp,595+          });596+        } catch {597+          // Already paired — update IP if provided598+          if (deviceIp) {599+            await updatePairedDevice(publicKeyHex, { lastKnownIp: deviceIp, lastSeenAt: Date.now() });600+          }601+        }602+        return { ok: true };603+      }558604       case "AUTOFILL_QUERY": {559605         if (!vault.isUnlocked()) {560606           return { ok: false, error: "VAULT_LOCKED" };@@ -1094,10 +1140,10 @@10941140 10951141       case "PASSKEY_AUTH_RESPONSE": {10961142         const { requestId, approved } = (msg.payload ?? {}) as {1097-          requestId?: string;1143+          requestId?: string | number;10981144           approved?: boolean;10991145         };1100-        if (!requestId || typeof approved !== 'boolean') {1146+        if (requestId === undefined || requestId === null || typeof approved !== 'boolean') {11011147           return { ok: false, error: 'requestId and approved required' };11021148         }11031149         await resolvePendingPasskeyRequest(Number(requestId), approved);

view file

src/background/notes.test.ts+259 −0
@@ -0,0 +1,259 @@1+import { describe, it, expect, beforeEach } from 'vitest';2+import { vi } from 'vitest';3+4+import type { Message, MessageResponse } from '../shared/types';5+6+type StorageShape = Record<string, unknown>;7+type MessageListener = (8+  msg: unknown,9+  sender: chrome.runtime.MessageSender,10+  sendResponse: (response: MessageResponse) => void11+) => boolean;12+13+describe('notes message handlers', () => {14+  let storage: StorageShape;15+  let sessionStorage: StorageShape;16+  let listener: MessageListener;17+18+  beforeEach(async () => {19+    vi.resetModules();20+    vi.useRealTimers();21+    storage = { peach_settings: { breachMonitoring: false } };22+    sessionStorage = {};23+24+    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));25+    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);26+27+    const chromeMock = {28+      runtime: {29+        id: 'peach-extension',30+        onMessage: {31+          addListener: vi.fn((fn: MessageListener) => { listener = fn; }),32+        },33+        sendMessage: vi.fn(async () => ({ ok: true })),34+      },35+      tabs: {36+        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {37+          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }38+          return Promise.resolve([{ id: 1 }]);39+        }),40+        sendMessage: vi.fn(async () => ({ ok: true })),41+      },42+      storage: {43+        local: {44+          get: vi.fn(async (key?: string | string[]) => {45+            if (!key) return storage;46+            if (typeof key === 'string') return { [key]: storage[key] };47+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});48+          }),49+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),50+          remove: vi.fn(async (keys: string | string[]) => {51+            const list = Array.isArray(keys) ? keys : [keys];52+            for (const key of list) delete storage[key];53+          }),54+        },55+        session: {56+          get: vi.fn(async (key?: string | string[]) => {57+            if (!key) return sessionStorage;58+            if (typeof key === 'string') return { [key]: sessionStorage[key] };59+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});60+          }),61+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),62+          remove: vi.fn(async (keys: string | string[]) => {63+            const list = Array.isArray(keys) ? keys : [keys];64+            for (const key of list) delete sessionStorage[key];65+          }),66+          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),67+        },68+      },69+      alarms: {70+        clear: vi.fn(), create: vi.fn(),71+        onAlarm: { addListener: vi.fn() },72+      },73+      idle: {74+        setDetectionInterval: vi.fn(),75+        onStateChanged: { addListener: vi.fn() },76+      },77+      scripting: { executeScript: vi.fn(async () => []) },78+    } as unknown as typeof chrome;79+80+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;81+82+    const messages = await import('./messages');83+    messages.setupMessageHandler();84+    const vault = await import('./vault');85+    await vault.ensureSettingsLoaded();86+  });87+88+  async function dispatch(message: Message): Promise<MessageResponse> {89+    return new Promise<MessageResponse>((resolve) => {90+      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);91+      expect(keepOpen).toBe(true);92+    });93+  }94+95+  async function setupVault(): Promise<void> {96+    const vault = await import('./vault');97+    await vault.createVault('test-pass-12345');98+  }99+100+  async function lockVault(): Promise<void> {101+    const vault = await import('./vault');102+    vault.lock();103+  }104+105+  describe('SAVE_NOTE handler', () => {106+    beforeEach(async () => { await setupVault(); });107+108+    it('creates a new note entry with type="note"', async () => {109+      const result = await dispatch({110+        type: 'SAVE_NOTE',111+        payload: { title: 'Test note', body: 'Hello world' },112+      });113+      expect(result.ok).toBe(true);114+      const note = (result as { data: Record<string, unknown> }).data;115+      expect(note.type).toBe('note');116+      expect(note.title).toBe('Test note');117+      expect(note.id).toBeTruthy();118+    });119+120+    it('assigns createdAt and modifiedAt timestamps', async () => {121+      const before = Date.now();122+      const result = await dispatch({123+        type: 'SAVE_NOTE',124+        payload: { title: 'Timestamps', body: '' },125+      });126+      const after = Date.now();127+      const note = (result as { data: Record<string, unknown> }).data;128+      expect(note.createdAt as number).toBeGreaterThanOrEqual(before);129+      expect(note.createdAt as number).toBeLessThanOrEqual(after);130+      expect(note.modifiedAt as number).toBeGreaterThanOrEqual(before);131+    });132+133+    it('updates existing note by id', async () => {134+      const created = await dispatch({135+        type: 'SAVE_NOTE',136+        payload: { title: 'Original', body: 'First' },137+      });138+      const createdNote = (created as { data: Record<string, unknown> }).data;139+      const updated = await dispatch({140+        type: 'SAVE_NOTE',141+        payload: { id: createdNote.id as string, title: 'Updated', body: 'Second' },142+      });143+      expect(updated.ok).toBe(true);144+      const updatedNote = (updated as { data: Record<string, unknown> }).data;145+      expect(updatedNote.id).toBe(createdNote.id);146+      expect(updatedNote.title).toBe('Updated');147+      expect(updatedNote.modifiedAt as number).toBeGreaterThanOrEqual(createdNote.modifiedAt as number);148+    });149+150+    it('does not change createdAt on update', async () => {151+      const created = await dispatch({152+        type: 'SAVE_NOTE',153+        payload: { title: 'Original', body: '' },154+      });155+      const createdNote = (created as { data: Record<string, unknown> }).data;156+      await new Promise(r => setTimeout(r, 10));157+      const updated = await dispatch({158+        type: 'SAVE_NOTE',159+        payload: { id: createdNote.id as string, title: 'Updated', body: '' },160+      });161+      const updatedNote = (updated as { data: Record<string, unknown> }).data;162+      expect(updatedNote.createdAt).toBe(createdNote.createdAt);163+    });164+165+    it('returns error when vault is locked', async () => {166+      await lockVault();167+      const result = await dispatch({168+        type: 'SAVE_NOTE',169+        payload: { title: 'Test', body: '' },170+      });171+      expect(result.ok).toBe(false);172+      expect((result as { error: string }).error).toMatch(/vault_locked/);173+    });174+175+    it('returns error when updating nonexistent id', async () => {176+      const result = await dispatch({177+        type: 'SAVE_NOTE',178+        payload: { id: 'nonexistent-id', title: 'Test', body: '' },179+      });180+      expect(result.ok).toBe(false);181+      expect((result as { error: string }).error).toMatch(/not_found/);182+    });183+  });184+185+  describe('DELETE_NOTE handler', () => {186+    it('soft-deletes a note and removes it from GET_NOTES results', async () => {187+      await setupVault();188+      const created = await dispatch({189+        type: 'SAVE_NOTE',190+        payload: { title: 'To delete', body: '' },191+      });192+      const note = (created as { data: Record<string, unknown> }).data;193+      const deleted = await dispatch({194+        type: 'DELETE_NOTE',195+        payload: { id: note.id as string },196+      });197+      expect(deleted.ok).toBe(true);198+      const notes = await dispatch({ type: 'GET_NOTES' });199+      const notesList = (notes as unknown as { data: Array<Record<string, unknown>> }).data;200+      expect(notesList.find((n) => n.id === note.id)).toBeUndefined();201+    });202+203+    it('returns error when vault is locked', async () => {204+      await setupVault();205+      await lockVault();206+      const result = await dispatch({207+        type: 'DELETE_NOTE',208+        payload: { id: 'some-id' },209+      });210+      expect(result.ok).toBe(false);211+    });212+  });213+214+  describe('notes in vault entries', () => {215+    it('note entries do not appear in autofill results', async () => {216+      await setupVault();217+      await dispatch({218+        type: 'SAVE_NOTE',219+        payload: { title: 'github.com', body: 'Some secret notes' },220+      });221+      const result = await dispatch({222+        type: 'AUTOFILL_QUERY',223+        payload: { domain: 'github.com' },224+      });225+      const data = (result as { data: Array<Record<string, unknown>> }).data;226+      const hasNote = data?.some((e) => e.type === 'note');227+      expect(hasNote).toBeFalsy();228+    });229+230+    it('note entries appear in vault search', async () => {231+      await setupVault();232+      await dispatch({233+        type: 'SAVE_NOTE',234+        payload: { title: 'SSH passphrase', body: 'my server key' },235+      });236+      const result = await dispatch({237+        type: 'SEARCH_VAULT',238+        payload: { query: 'SSH' },239+      });240+      const entries = (result as unknown as { data: Array<Record<string, unknown>> }).data;241+      const found = entries?.some((e) => e.type === 'note' && e.title === 'SSH passphrase');242+      expect(found).toBe(true);243+    });244+245+    it('note body is searched', async () => {246+      await setupVault();247+      await dispatch({248+        type: 'SAVE_NOTE',249+        payload: { title: 'Random title', body: 'backup codes ABCD1234' },250+      });251+      const result = await dispatch({252+        type: 'SEARCH_VAULT',253+        payload: { query: 'ABCD1234' },254+      });255+      const entries = (result as unknown as { data: Array<unknown> }).data;256+      expect(entries?.length).toBeGreaterThan(0);257+    });258+  });259+});

view file

src/background/recovery-words.test.ts+277 −0
@@ -0,0 +1,277 @@1+import { describe, it, expect, beforeEach } from 'vitest';2+import { vi } from 'vitest';3+4+import type { Message, MessageResponse } from '../shared/types';5+import { WORD_LIST } from '../shared/recovery-word-list';6+7+type StorageShape = Record<string, unknown>;8+type MessageListener = (9+  msg: unknown,10+  sender: chrome.runtime.MessageSender,11+  sendResponse: (response: MessageResponse) => void12+) => boolean;13+14+describe('recovery words handlers', () => {15+  let storage: StorageShape;16+  let sessionStorage: StorageShape;17+  let listener: MessageListener;18+19+  beforeEach(async () => {20+    vi.resetModules();21+    vi.useRealTimers();22+    storage = { peach_settings: { breachMonitoring: false } };23+    sessionStorage = {};24+25+    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));26+    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);27+28+    const chromeMock = {29+      runtime: {30+        id: 'peach-extension',31+        onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },32+        sendMessage: vi.fn(async () => ({ ok: true })),33+      },34+      tabs: {35+        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {36+          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }37+          return Promise.resolve([{ id: 1 }]);38+        }),39+        sendMessage: vi.fn(async () => ({ ok: true })),40+      },41+      storage: {42+        local: {43+          get: vi.fn(async (key?: string | string[]) => {44+            if (!key) return storage;45+            if (typeof key === 'string') return { [key]: storage[key] };46+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});47+          }),48+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),49+          remove: vi.fn(async (keys: string | string[]) => {50+            const list = Array.isArray(keys) ? keys : [keys];51+            for (const key of list) delete storage[key];52+          }),53+        },54+        session: {55+          get: vi.fn(async (key?: string | string[]) => {56+            if (!key) return sessionStorage;57+            if (typeof key === 'string') return { [key]: sessionStorage[key] };58+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});59+          }),60+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),61+          remove: vi.fn(async (keys: string | string[]) => {62+            const list = Array.isArray(keys) ? keys : [keys];63+            for (const key of list) delete sessionStorage[key];64+          }),65+          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),66+        },67+      },68+      alarms: {69+        clear: vi.fn(), create: vi.fn(),70+        onAlarm: { addListener: vi.fn() },71+      },72+      idle: {73+        setDetectionInterval: vi.fn(),74+        onStateChanged: { addListener: vi.fn() },75+      },76+      scripting: { executeScript: vi.fn(async () => []) },77+    } as unknown as typeof chrome;78+79+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;80+81+    const messages = await import('./messages');82+    messages.setupMessageHandler();83+    const vault = await import('./vault');84+    await vault.ensureSettingsLoaded();85+  });86+87+  async function dispatch(message: Message): Promise<MessageResponse> {88+    return new Promise<MessageResponse>((resolve) => {89+      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);90+      expect(keepOpen).toBe(true);91+    });92+  }93+94+  async function setupUnlockedVault(): Promise<void> {95+    const vault = await import('./vault');96+    await vault.createVault('test-pass-12345');97+  }98+99+  async function lockVault(): Promise<void> {100+    const vault = await import('./vault');101+    vault.lock();102+  }103+104+  async function mockNoPro(): Promise<void> {105+    const license = await import('./license');106+    vi.spyOn(license, 'isProUser').mockResolvedValue(false);107+  }108+109+  describe('GET_RECOVERY_WORDS handler', () => {110+    beforeEach(async () => { await setupUnlockedVault(); });111+112+    it('returns 12 words', async () => {113+      const result = await dispatch({ type: 'GET_RECOVERY_WORDS' });114+      expect(result.ok).toBe(true);115+      const words = (result as { data: string[] }).data;116+      expect(words).toHaveLength(12);117+    });118+119+    it('all words are from the BIP39 word list', async () => {120+      const result = await dispatch({ type: 'GET_RECOVERY_WORDS' });121+      const words = (result as { data: string[] }).data;122+      for (const word of words) {123+        expect(WORD_LIST).toContain(word);124+      }125+    });126+127+    it('returns same words on subsequent calls (deterministic)', async () => {128+      const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS' });129+      const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS' });130+      expect((r1 as { data: string[] }).data).toEqual((r2 as { data: string[] }).data);131+    });132+133+    it('returns error when vault is locked', async () => {134+      await lockVault();135+      const result = await dispatch({ type: 'GET_RECOVERY_WORDS' });136+      expect(result.ok).toBe(false);137+    });138+  });139+140+  describe('VALIDATE_RECOVERY_WORDS handler', () => {141+    beforeEach(async () => { await setupUnlockedVault(); });142+143+    it('returns no invalid words for correct list', async () => {144+      const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });145+      const words = (r as { data: string[] }).data;146+      const result = await dispatch({147+        type: 'VALIDATE_RECOVERY_WORDS',148+        payload: { words },149+      });150+      const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;151+      expect(data.invalid).toEqual([]);152+    });153+154+    it('returns invalid words for unknown words', async () => {155+      const result = await dispatch({156+        type: 'VALIDATE_RECOVERY_WORDS',157+        payload: { words: ['notaword', 'alsonotaword'] },158+      });159+      const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;160+      const invalidWords = data.invalid.map((x) => x.word);161+      expect(invalidWords).toContain('notaword');162+      expect(invalidWords).toContain('alsonotaword');163+    });164+165+    it('is case-insensitive for validation', async () => {166+      const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });167+      const words = (r as { data: string[] }).data;168+      const uppercased = words.map((w: string) => w.toUpperCase());169+      const result = await dispatch({170+        type: 'VALIDATE_RECOVERY_WORDS',171+        payload: { words: uppercased },172+      });173+      const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;174+      expect(data.invalid).toEqual([]);175+    });176+  });177+178+  describe('RECOVER_WITH_WORDS handler', () => {179+    it('recovery succeeds with correct words and new passphrase', async () => {180+      await setupUnlockedVault();181+      const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });182+      const words = (r as { data: string[] }).data;183+      await lockVault();184+185+      const result = await dispatch({186+        type: 'RECOVER_WITH_WORDS',187+        payload: { words, newPassword: 'new-passphrase' },188+      });189+      expect(result.ok).toBe(true);190+      const unlocked = await dispatch({ type: 'IS_UNLOCKED' });191+      expect((unlocked as { data: boolean }).data).toBe(true);192+    });193+194+    it('fails with wrong recovery words', async () => {195+      await setupUnlockedVault();196+      await lockVault();197+      const fakeWords = Array(12).fill('abandon');198+      const result = await dispatch({199+        type: 'RECOVER_WITH_WORDS',200+        payload: { words: fakeWords, newPassword: 'new-pass' },201+      });202+      expect(result.ok).toBe(false);203+      expect((result as { error: string }).error).toMatch(/invalid/);204+    });205+206+    it('fails with partial word list (fewer than 12)', async () => {207+      await setupUnlockedVault();208+      const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });209+      const words = (r as { data: string[] }).data;210+      await lockVault();211+      const partial = words.slice(0, 6);212+      const result = await dispatch({213+        type: 'RECOVER_WITH_WORDS',214+        payload: { words: partial, newPassword: 'new-pass' },215+      });216+      expect(result.ok).toBe(false);217+    });218+219+    it('new passphrase works after recovery', async () => {220+      await setupUnlockedVault();221+      const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });222+      const words = (r as { data: string[] }).data;223+      await lockVault();224+      await dispatch({225+        type: 'RECOVER_WITH_WORDS',226+        payload: { words, newPassword: 'brand-new-password' },227+      });228+      await lockVault();229+      const unlock = await dispatch({230+        type: 'UNLOCK',231+        payload: { password: 'brand-new-password' },232+      });233+      expect(unlock.ok).toBe(true);234+    });235+236+    it('old passphrase does not work after recovery', async () => {237+      await setupUnlockedVault();238+      const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });239+      const words = (r as { data: string[] }).data;240+      await lockVault();241+      await dispatch({242+        type: 'RECOVER_WITH_WORDS',243+        payload: { words, newPassword: 'brand-new-password' },244+      });245+      await lockVault();246+      const unlock = await dispatch({247+        type: 'UNLOCK',248+        payload: { password: 'test-pass-12345' },249+      });250+      expect(unlock.ok).toBe(false);251+    });252+253+    it('fresh recovery words are generated after password change', async () => {254+      await setupUnlockedVault();255+      const before = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;256+      await dispatch({257+        type: 'CHANGE_PASSWORD',258+        payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-67890' },259+      });260+      const after = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;261+      expect(after).not.toEqual(before);262+    });263+264+    it('recovery is not Pro-gated', async () => {265+      await setupUnlockedVault();266+      await mockNoPro();267+      const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });268+      const words = (r as { data: string[] }).data;269+      await lockVault();270+      const result = await dispatch({271+        type: 'RECOVER_WITH_WORDS',272+        payload: { words, newPassword: 'new-pass' },273+      });274+      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);275+    });276+  });277+});

view file

src/background/sync-info-sheet.test.ts+177 −0
@@ -0,0 +1,177 @@1+import { describe, it, expect, beforeEach } from 'vitest';2+import { vi } from 'vitest';3+4+import type { Message, MessageResponse } from '../shared/types';5+6+type StorageShape = Record<string, unknown>;7+type MessageListener = (8+  msg: unknown,9+  sender: chrome.runtime.MessageSender,10+  sendResponse: (response: MessageResponse) => void11+) => boolean;12+13+describe('hasPaired flag', () => {14+  let storage: StorageShape;15+16+  function installChromeMock(s: StorageShape): void {17+    const chromeMock = {18+      storage: {19+        local: {20+          get: vi.fn(async (key?: string | string[]) => {21+            if (!key) return s;22+            if (typeof key === 'string') return { [key]: s[key] };23+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = s[k]; return acc; }, {});24+          }),25+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(s, next); }),26+          remove: vi.fn(async (keys: string | string[]) => {27+            const list = Array.isArray(keys) ? keys : [keys];28+            for (const key of list) delete s[key];29+          }),30+        },31+      },32+    } as unknown as typeof chrome;33+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;34+  }35+36+  beforeEach(async () => {37+    vi.resetModules();38+    storage = {};39+    installChromeMock(storage);40+  });41+42+  async function clearStorage(): Promise<void> {43+    for (const key of Object.keys(storage)) delete storage[key];44+  }45+46+  it('getHasPaired returns false when never paired', async () => {47+    await clearStorage();48+    const { getHasPaired } = await import('./sync-storage');49+    const result = await getHasPaired();50+    expect(result).toBe(false);51+  });52+53+  it('setHasPaired persists across calls', async () => {54+    const { setHasPaired, getHasPaired } = await import('./sync-storage');55+    await setHasPaired();56+    const result = await getHasPaired();57+    expect(result).toBe(true);58+  });59+60+  it('hasPaired remains true after multiple calls to setHasPaired', async () => {61+    const { setHasPaired, getHasPaired } = await import('./sync-storage');62+    await setHasPaired();63+    await setHasPaired(); // idempotent64+    const result = await getHasPaired();65+    expect(result).toBe(true);66+  });67+});68+69+describe('PAIRING_COMPLETED handler', () => {70+  let storage: StorageShape;71+  let sessionStorage: StorageShape;72+  let listener: MessageListener;73+74+  beforeEach(async () => {75+    vi.resetModules();76+    vi.useRealTimers();77+    storage = { peach_settings: { breachMonitoring: false } };78+    sessionStorage = {};79+80+    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));81+    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);82+83+    const chromeMock = {84+      runtime: {85+        id: 'peach-extension',86+        onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },87+        sendMessage: vi.fn(async () => ({ ok: true })),88+      },89+      tabs: {90+        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {91+          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }92+          return Promise.resolve([{ id: 1 }]);93+        }),94+        sendMessage: vi.fn(async () => ({ ok: true })),95+      },96+      storage: {97+        local: {98+          get: vi.fn(async (key?: string | string[]) => {99+            if (!key) return storage;100+            if (typeof key === 'string') return { [key]: storage[key] };101+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});102+          }),103+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),104+          remove: vi.fn(async (keys: string | string[]) => {105+            const list = Array.isArray(keys) ? keys : [keys];106+            for (const key of list) delete storage[key];107+          }),108+        },109+        session: {110+          get: vi.fn(async (key?: string | string[]) => {111+            if (!key) return sessionStorage;112+            if (typeof key === 'string') return { [key]: sessionStorage[key] };113+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});114+          }),115+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),116+          remove: vi.fn(async (keys: string | string[]) => {117+            const list = Array.isArray(keys) ? keys : [keys];118+            for (const key of list) delete sessionStorage[key];119+          }),120+          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),121+        },122+      },123+      alarms: {124+        clear: vi.fn(), create: vi.fn(),125+        onAlarm: { addListener: vi.fn() },126+      },127+      idle: {128+        setDetectionInterval: vi.fn(),129+        onStateChanged: { addListener: vi.fn() },130+      },131+      scripting: { executeScript: vi.fn(async () => []) },132+    } as unknown as typeof chrome;133+134+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;135+136+    const messages = await import('./messages');137+    messages.setupMessageHandler();138+    const vault = await import('./vault');139+    await vault.ensureSettingsLoaded();140+  });141+142+  async function dispatch(message: Message): Promise<MessageResponse> {143+    return new Promise<MessageResponse>((resolve) => {144+      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);145+      expect(keepOpen).toBe(true);146+    });147+  }148+149+  it('flag is set when PAIRING_COMPLETED fires', async () => {150+    await dispatch({151+      type: 'PAIRING_COMPLETED',152+      payload: {153+        deviceName: 'iPhone',154+        publicKeyHex: 'abc123',155+        deviceIp: '192.168.1.50',156+      },157+    });158+    const { getHasPaired } = await import('./sync-storage');159+    const paired = await getHasPaired();160+    expect(paired).toBe(true);161+  });162+163+  it('PAIRING_COMPLETED stores device with lastKnownIp and deviceName', async () => {164+    await dispatch({165+      type: 'PAIRING_COMPLETED',166+      payload: {167+        deviceName: 'iPhone',168+        publicKeyHex: 'abc123',169+        deviceIp: '192.168.1.50',170+      },171+    });172+    const { getPairedDevices } = await import('./sync-storage');173+    const devices = await getPairedDevices();174+    expect(devices[0].lastKnownIp).toBe('192.168.1.50');175+    expect(devices[0].displayName).toBe('iPhone');176+  });177+});

view file

src/background/sync-pro-gate.test.ts+169 −0
@@ -0,0 +1,169 @@1+import { describe, it, expect, beforeEach } from 'vitest';2+import { vi } from 'vitest';3+4+import type { Message, MessageResponse } from '../shared/types';5+6+type StorageShape = Record<string, unknown>;7+type MessageListener = (8+  msg: unknown,9+  sender: chrome.runtime.MessageSender,10+  sendResponse: (response: MessageResponse) => void11+) => boolean;12+13+describe('sync Pro gating', () => {14+  let storage: StorageShape;15+  let sessionStorage: StorageShape;16+  let listener: MessageListener;17+18+  beforeEach(async () => {19+    vi.resetModules();20+    vi.useRealTimers();21+    storage = { peach_settings: { breachMonitoring: false } };22+    sessionStorage = {};23+24+    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));25+    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);26+    // Default: desktop (no Capacitor)27+    (globalThis as Record<string, unknown>).Capacitor = undefined;28+29+    const chromeMock = {30+      runtime: {31+        id: 'peach-extension',32+        onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },33+        sendMessage: vi.fn(async () => ({ ok: true })),34+      },35+      tabs: {36+        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {37+          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }38+          return Promise.resolve([{ id: 1 }]);39+        }),40+        sendMessage: vi.fn(async () => ({ ok: true })),41+      },42+      storage: {43+        local: {44+          get: vi.fn(async (key?: string | string[]) => {45+            if (!key) return storage;46+            if (typeof key === 'string') return { [key]: storage[key] };47+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});48+          }),49+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),50+          remove: vi.fn(async (keys: string | string[]) => {51+            const list = Array.isArray(keys) ? keys : [keys];52+            for (const key of list) delete storage[key];53+          }),54+        },55+        session: {56+          get: vi.fn(async (key?: string | string[]) => {57+            if (!key) return sessionStorage;58+            if (typeof key === 'string') return { [key]: sessionStorage[key] };59+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});60+          }),61+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),62+          remove: vi.fn(async (keys: string | string[]) => {63+            const list = Array.isArray(keys) ? keys : [keys];64+            for (const key of list) delete sessionStorage[key];65+          }),66+          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),67+        },68+      },69+      alarms: {70+        clear: vi.fn(), create: vi.fn(),71+        onAlarm: { addListener: vi.fn() },72+      },73+      idle: {74+        setDetectionInterval: vi.fn(),75+        onStateChanged: { addListener: vi.fn() },76+      },77+      scripting: { executeScript: vi.fn(async () => []) },78+    } as unknown as typeof chrome;79+80+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;81+82+    const messages = await import('./messages');83+    messages.setupMessageHandler();84+    const vault = await import('./vault');85+    await vault.ensureSettingsLoaded();86+  });87+88+  async function dispatch(message: Message): Promise<MessageResponse> {89+    return new Promise<MessageResponse>((resolve) => {90+      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);91+      expect(keepOpen).toBe(true);92+    });93+  }94+95+  async function mockNoPro(): Promise<void> {96+    const license = await import('./license');97+    vi.spyOn(license, 'isProUser').mockResolvedValue(false);98+  }99+100+  async function mockPro(): Promise<void> {101+    const license = await import('./license');102+    vi.spyOn(license, 'isProUser').mockResolvedValue(true);103+  }104+105+  function mockDesktop(): void {106+    (globalThis as Record<string, unknown>).Capacitor = undefined;107+  }108+109+  function mockNative(): void {110+    (globalThis as Record<string, unknown>).Capacitor = { isNativePlatform: () => true };111+  }112+113+  describe('pairing initiation — free', () => {114+    it('START_PAIRING_SERVER succeeds without Pro', async () => {115+      await mockNoPro();116+      mockDesktop();117+      const result = await dispatch({ type: 'START_PAIRING_SERVER' });118+      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);119+    });120+121+    it('ACCEPT_PAIRING succeeds without Pro', async () => {122+      await mockNoPro();123+      const result = await dispatch({124+        type: 'ACCEPT_PAIRING',125+        payload: { token: 'test-token' },126+      });127+      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);128+    });129+130+    it('SCAN_PAIRING_QR does not return pro_required (returns android_only on desktop)', async () => {131+      await mockNoPro();132+      mockDesktop();133+      const result = await dispatch({134+        type: 'SCAN_PAIRING_QR',135+        payload: { token: '123456' },136+      });137+      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);138+    });139+  });140+141+  describe('ongoing sync — Pro required', () => {142+    it('TRIGGER_SYNC is Pro-gated on desktop', async () => {143+      await mockNoPro();144+      mockDesktop();145+      const result = await dispatch({ type: 'TRIGGER_SYNC' });146+      expect(result.ok).toBe(false);147+      expect((result as { error: string }).error).toMatch(/pro_required/);148+    });149+150+    it('TRIGGER_SYNC with Pro on desktop does not return pro_required', async () => {151+      await mockPro();152+      mockDesktop();153+      const vault = await import('./vault');154+      await vault.createVault('test-pass-12345');155+      const result = await dispatch({ type: 'TRIGGER_SYNC' });156+      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);157+    });158+159+    it('TRIGGER_SYNC with Pro on native succeeds', async () => {160+      await mockPro();161+      mockNative();162+      const vault = await import('./vault');163+      await vault.createVault('test-pass-12345');164+      const result = await dispatch({ type: 'TRIGGER_SYNC' });165+      expect(result.ok).toBe(true);166+      expect((result as { data: { mode: string } }).data?.mode).toBe('server');167+    });168+  });169+});

view file

src/background/sync-screen.test.ts+175 −0
@@ -0,0 +1,175 @@1+import { describe, it, expect, beforeEach } from 'vitest';2+import { vi } from 'vitest';3+4+import type { Message, MessageResponse } from '../shared/types';5+6+type StorageShape = Record<string, unknown>;7+type MessageListener = (8+  msg: unknown,9+  sender: chrome.runtime.MessageSender,10+  sendResponse: (response: MessageResponse) => void11+) => boolean;12+13+describe('sync screen handlers', () => {14+  let storage: StorageShape;15+  let sessionStorage: StorageShape;16+  let listener: MessageListener;17+18+  beforeEach(async () => {19+    vi.resetModules();20+    vi.useRealTimers();21+    storage = { peach_settings: { breachMonitoring: false } };22+    sessionStorage = {};23+24+    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));25+    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);26+27+    const chromeMock = {28+      runtime: {29+        id: 'peach-extension',30+        onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },31+        sendMessage: vi.fn(async () => ({ ok: true })),32+      },33+      tabs: {34+        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {35+          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }36+          return Promise.resolve([{ id: 1 }]);37+        }),38+        sendMessage: vi.fn(async () => ({ ok: true })),39+      },40+      storage: {41+        local: {42+          get: vi.fn(async (key?: string | string[]) => {43+            if (!key) return storage;44+            if (typeof key === 'string') return { [key]: storage[key] };45+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});46+          }),47+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),48+          remove: vi.fn(async (keys: string | string[]) => {49+            const list = Array.isArray(keys) ? keys : [keys];50+            for (const key of list) delete storage[key];51+          }),52+        },53+        session: {54+          get: vi.fn(async (key?: string | string[]) => {55+            if (!key) return sessionStorage;56+            if (typeof key === 'string') return { [key]: sessionStorage[key] };57+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});58+          }),59+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),60+          remove: vi.fn(async (keys: string | string[]) => {61+            const list = Array.isArray(keys) ? keys : [keys];62+            for (const key of list) delete sessionStorage[key];63+          }),64+          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),65+        },66+      },67+      alarms: {68+        clear: vi.fn(), create: vi.fn(),69+        onAlarm: { addListener: vi.fn() },70+      },71+      idle: {72+        setDetectionInterval: vi.fn(),73+        onStateChanged: { addListener: vi.fn() },74+      },75+      scripting: { executeScript: vi.fn(async () => []) },76+    } as unknown as typeof chrome;77+78+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;79+80+    const messages = await import('./messages');81+    messages.setupMessageHandler();82+    const vault = await import('./vault');83+    await vault.ensureSettingsLoaded();84+  });85+86+  async function dispatch(message: Message): Promise<MessageResponse> {87+    return new Promise<MessageResponse>((resolve) => {88+      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);89+      expect(keepOpen).toBe(true);90+    });91+  }92+93+  function storageSet(key: string, value: unknown): void {94+    storage[key] = value;95+  }96+97+  describe('GET_PAIRED_DEVICES handler', () => {98+    it('returns empty array when no devices paired', async () => {99+      const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });100+      expect(result.ok).toBe(true);101+      const data = (result as { data: { devices: unknown[] } }).data;102+      expect(data.devices).toEqual([]);103+    });104+105+    it('returns paired devices list', async () => {106+      storage['peach_paired_devices'] = [107+        {108+          deviceId: 'abc',109+          displayName: 'MacBook',110+          x25519PublicKey: 'abc',111+          pairedAt: Date.now(),112+          lastKnownIp: '192.168.1.1',113+        },114+      ];115+      const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });116+      const data = (result as { data: { devices: Array<Record<string, unknown>> } }).data;117+      expect(data.devices.length).toBe(1);118+      expect(data.devices[0].displayName).toBe('MacBook');119+    });120+121+    it('does not expose private keys in paired devices', async () => {122+      storage['peach_paired_devices'] = [123+        {124+          deviceId: 'abc',125+          displayName: 'MacBook',126+          x25519PublicKey: 'abc',127+          pairedAt: Date.now(),128+          lastKnownIp: '192.168.1.1',129+        },130+      ];131+      const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });132+      const data = (result as { data: { devices: Array<Record<string, unknown>> } }).data;133+      expect(data.devices[0].privateKey).toBeUndefined();134+    });135+  });136+137+  describe('GET_SYNC_STATUS handler', () => {138+    it('returns never status when never synced', async () => {139+      const result = await dispatch({ type: 'GET_SYNC_STATUS' });140+      expect(result.ok).toBe(true);141+      const data = (result as { data: { status: string; lastSyncAt: unknown } }).data;142+      expect(data.status).toBe('never');143+      expect(data.lastSyncAt).toBeNull();144+    });145+146+    it('returns up_to_date after successful sync', async () => {147+      storageSet('peach_last_sync_at', String(Date.now()));148+      const result = await dispatch({ type: 'GET_SYNC_STATUS' });149+      const data = (result as { data: { status: string; lastSyncAt: unknown } }).data;150+      expect(data.status).toBe('up_to_date');151+      expect(data.lastSyncAt).toBeTruthy();152+    });153+  });154+155+  describe('RESET_SYNC_IDENTITY handler', () => {156+    it('is Pro-gated', async () => {157+      const result = await dispatch({ type: 'RESET_SYNC_IDENTITY' });158+      expect(result.ok).toBe(false);159+      expect((result as { error: string }).error).toMatch(/pro_required/);160+    });161+162+    it('clears paired devices on reset for Pro users', async () => {163+      storage['peach_paired_devices'] = [164+        { deviceId: 'a', displayName: 'iPhone', x25519PublicKey: 'pk-a', pairedAt: 1 },165+        { deviceId: 'b', displayName: 'MacBook', x25519PublicKey: 'pk-b', pairedAt: 2 },166+      ];167+      const license = await import('./license');168+      vi.spyOn(license, 'isProUser').mockResolvedValue(true);169+      const result = await dispatch({ type: 'RESET_SYNC_IDENTITY' });170+      expect(result.ok).toBe(true);171+      expect((result as { data: { removedDevices: number } }).data?.removedDevices).toBe(2);172+      expect(storage['peach_paired_devices']).toEqual([]);173+    });174+  });175+});

view file

src/background/vault.ts+16 −7
@@ -291,14 +291,21 @@291291   // Decrypt and cache the root key. The root key bytes were hex-encoded before292292   // encryption so the ciphertext is valid UTF-8. We inline the AES-GCM decrypt293293   // here to avoid passing a CryptoKey to the string-based encrypt/decrypt helpers.294-  const combined = unhex(wrappedRootKey + wrappedNonce + wrappedTag);295-  const nonceU8 = combined.slice(0, 12);296-  const ciphertext = combined.slice(12, combined.length - 16);297-  const storedTag = combined.slice(combined.length - 16);294+  // Each component is stored separately in the vault slot; decode them individually295+  // so the nonce is always exactly the 12 bytes from wrappedNonce.296+  const nonceRaw = unhex(wrappedNonce);297+  const ciphertextRaw = unhex(wrappedRootKey);298+  const tagRaw = unhex(wrappedTag);299+  // Combine ciphertext and tag for AES-GCM (expects ciphertext||tag).300+  // Use explicit ArrayBuffer allocations to satisfy TS BufferSource constraints.301+  const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;302+  const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);303+  combined.set(ciphertextRaw, 0);304+  combined.set(tagRaw, ciphertextRaw.length);298305   const raw = await crypto.subtle.decrypt(299306     { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },300307     recoveryKey,301-    new Uint8Array([...ciphertext, ...storedTag])308+    combined302309   );303310   const decryptedHex = new TextDecoder().decode(raw);304311   rootKey = unhex(decryptedHex);@@ -360,8 +367,10 @@360367 ): Promise<void> {361368   const slotA = await loadSlot(SLOT_A);362369   if (!slotA) throw new Error("missing slot A");363-  await reencryptVaultForNewKey(newKey, slotA);364-  zeroKey(derivedKey!);370+  // reencryptVaultForNewKey must receive the updated salt so that subsequent371+  // UNLOCK calls with the new password can re-derive the correct key.372+  await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });373+  if (derivedKey) zeroKey(derivedKey);365374   derivedKey = newKey;366375   if (!duressConfigured) {367376     await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));

view file

src/content/passkey-toast/passkey-toast.test.ts+263 −0
@@ -0,0 +1,263 @@1+// @vitest-environment jsdom2+3+import { describe, it, expect, vi, beforeEach } from 'vitest';4+5+// ── chrome mock ──────────────────────────────────────────────────────────────6+7+type MessageListener = (8+  msg: unknown,9+  sender: unknown,10+  sendResponse: (r: unknown) => void11+) => boolean;12+13+type StorageShape = Record<string, unknown>;14+15+function installChromeMock(): {16+  storage: StorageShape;17+  sessionStorage: StorageShape;18+  listener: () => MessageListener;19+} {20+  const storage: StorageShape = {};21+  const sessionStorage: StorageShape = {};22+  let messageListener: MessageListener | null = null;23+24+  const chromeMock = {25+    runtime: {26+      id: 'peach-extension',27+      sendMessage: vi.fn(async () => ({ ok: true })),28+      onMessage: {29+        addListener: vi.fn((fn: MessageListener) => {30+          messageListener = fn;31+        }),32+      },33+    },34+    storage: {35+      local: {36+        get: vi.fn(async (key?: string | string[]) => {37+          if (!key) return storage;38+          if (typeof key === 'string') return { [key]: storage[key] };39+          return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});40+        }),41+        set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),42+        remove: vi.fn(async (k: string | string[]) => {43+          const keys = Array.isArray(k) ? k : [k];44+          for (const key of keys) delete storage[key];45+        }),46+      },47+      session: {48+        get: vi.fn(async (key?: string | string[]) => {49+          if (!key) return sessionStorage;50+          if (typeof key === 'string') return { [key]: sessionStorage[key] };51+          return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});52+        }),53+        set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),54+        remove: vi.fn(async (k: string | string[]) => {55+          const keys = Array.isArray(k) ? k : [k];56+          for (const key of keys) delete sessionStorage[key];57+        }),58+      },59+    },60+  };61+  (globalThis as { chrome?: typeof chrome }).chrome = chromeMock as unknown as typeof chrome;62+63+  return {64+    storage,65+    sessionStorage,66+    listener: () => messageListener!,67+  };68+}69+70+// ── helpers ──────────────────────────────────────────────────────────────────71+72+const mockPayload = {73+  site: 'example.com',74+  displayName: 'Example',75+  username: 'june',76+  requestId: 'req-1',77+};78+79+function sendToContentScript(80+  listener: MessageListener,81+  msg: { type: string; payload?: unknown },82+): void {83+  const sender = {};84+  const sendResponse = vi.fn();85+  listener(msg, sender, sendResponse);86+}87+88+// ── PASSKEY_AUTH_RESPONSE background handler ─────────────────────────────────89+90+describe('PASSKEY_AUTH_RESPONSE handler', () => {91+  let storage: StorageShape;92+  let sessionStorage: StorageShape;93+  let listener: (() => MessageListener);94+95+  beforeEach(async () => {96+    vi.resetModules();97+    const mocks = installChromeMock();98+    storage = mocks.storage;99+    sessionStorage = mocks.sessionStorage;100+    listener = mocks.listener;101+    storage['peach_settings'] = { breachMonitoring: false };102+    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));103+    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);104+    (globalThis as Record<string, unknown>).chrome = (globalThis as Record<string, unknown>).chrome ?? {};105+    ((globalThis as Record<string, unknown>).chrome as Record<string, unknown>).tabs = {106+      query: vi.fn((_, cb?: (tabs: unknown[]) => void) => { if (cb) cb([]); return Promise.resolve([]); }),107+      sendMessage: vi.fn(async () => ({ ok: true })),108+    };109+    const msgs = await import('../../background/messages');110+    msgs.setupMessageHandler();111+    const vault = await import('../../background/vault');112+    await vault.ensureSettingsLoaded();113+  });114+115+  async function dispatch(msg: { type: string; payload?: unknown }): Promise<Record<string, unknown>> {116+    return new Promise((resolve) => {117+      const keepOpen = listener()(msg as unknown as Parameters<MessageListener>[0], { id: 'peach-extension' }, (r) => resolve(r as Record<string, unknown>));118+      expect(keepOpen).toBe(true);119+    });120+  }121+122+  it('returns ok:true when approved:true and payload is valid', async () => {123+    const result = await dispatch({124+      type: 'PASSKEY_AUTH_RESPONSE',125+      payload: { requestId: 42, approved: true },126+    });127+    expect(result.ok).toBe(true);128+  });129+130+  it('returns ok:true when approved:false and payload is valid', async () => {131+    const result = await dispatch({132+      type: 'PASSKEY_AUTH_RESPONSE',133+      payload: { requestId: 42, approved: false },134+    });135+    expect(result.ok).toBe(true);136+  });137+138+  it('returns error when requestId is missing', async () => {139+    const result = await dispatch({140+      type: 'PASSKEY_AUTH_RESPONSE',141+      payload: { approved: true },142+    });143+    expect(result.ok).toBe(false);144+    expect(result.error).toMatch(/requestId/);145+  });146+147+  it('returns error when approved flag is missing', async () => {148+    const result = await dispatch({149+      type: 'PASSKEY_AUTH_RESPONSE',150+      payload: { requestId: 42 },151+    });152+    expect(result.ok).toBe(false);153+    expect(result.error).toMatch(/requestId/);154+  });155+});156+157+// ── Toast injection ───────────────────────────────────────────────────────────158+159+describe('toast injection', () => {160+  let contentListener: MessageListener;161+162+  beforeEach(async () => {163+    vi.resetModules();164+165+    // Reset DOM166+    document.body.innerHTML = '';167+168+    const chromeMock = {169+      runtime: {170+        sendMessage: vi.fn(async () => ({ ok: true })),171+        onMessage: {172+          addListener: vi.fn((fn: MessageListener) => {173+            contentListener = fn;174+          }),175+        },176+      },177+      storage: {178+        local: {179+          get: vi.fn(async () => ({})),180+          set: vi.fn(async () => {}),181+        },182+      },183+    };184+    (globalThis as { chrome?: unknown }).chrome = chromeMock;185+186+    // Import the content script to register its listener187+    await import('./inject');188+  });189+190+  it('shows toast when SHOW_PASSKEY_TOAST message received', () => {191+    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });192+    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();193+  });194+195+  it('second SHOW_PASSKEY_TOAST replaces existing toast', () => {196+    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });197+    sendToContentScript(contentListener, {198+      type: 'SHOW_PASSKEY_TOAST',199+      payload: { ...mockPayload, requestId: 'req-2' },200+    });201+    expect(document.querySelectorAll('#peach-passkey-toast-root').length).toBe(1);202+  });203+204+  it('uses shadow DOM — container has a shadowRoot', () => {205+    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });206+    const root = document.querySelector('#peach-passkey-toast-root');207+    // shadow mode is 'closed', so shadowRoot returns null from the outside;208+    // we verify the element exists and is positioned correctly instead.209+    expect(root).toBeTruthy();210+    expect(root?.tagName.toLowerCase()).toBe('div');211+  });212+213+  it('DISMISS_PASSKEY_TOAST removes toast', () => {214+    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });215+    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();216+    sendToContentScript(contentListener, { type: 'DISMISS_PASSKEY_TOAST' });217+    // Container element stays in DOM; the toast content inside it is cleared218+    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();219+  });220+});221+222+// ── Autofill blocking while toast active ──────────────────────────────────────223+224+describe('autofill blocking while toast active', () => {225+  let contentListener: MessageListener;226+227+  beforeEach(async () => {228+    vi.resetModules();229+    document.body.innerHTML = '';230+231+    const chromeMock = {232+      runtime: {233+        sendMessage: vi.fn(async () => ({ ok: true })),234+        onMessage: {235+          addListener: vi.fn((fn: MessageListener) => {236+            contentListener = fn;237+          }),238+        },239+      },240+      storage: {241+        local: {242+          get: vi.fn(async () => ({})),243+          set: vi.fn(async () => {}),244+        },245+      },246+    };247+    (globalThis as { chrome?: unknown }).chrome = chromeMock;248+    await import('./inject');249+  });250+251+  it('toast is shown after SHOW_PASSKEY_TOAST', () => {252+    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });253+    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();254+  });255+256+  it('toast is cleared after DISMISS_PASSKEY_TOAST', () => {257+    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });258+    sendToContentScript(contentListener, { type: 'DISMISS_PASSKEY_TOAST' });259+    // The container persists; the toast content is cleaned up260+    const root = document.querySelector('#peach-passkey-toast-root');261+    expect(root).toBeTruthy();262+  });263+});

view file

src/popup/codex/codex.test.ts+460 −0
@@ -0,0 +1,460 @@1+import { describe, it, expect, beforeEach } from 'vitest';2+import { vi } from 'vitest';3+import { deflate, inflate } from 'pako';4+5+import {6+  buildChunkEnvelope,7+  verifyChunkIntegrity,8+  getOrCreateCodexVerifyCode,9+  clearVerifyCode,10+} from './helpers';11+import { encodeWithErasure, decodeWithErasure } from './erasure';12+import { CodexError } from '../../shared/codex-types';13+14+import type { Message, MessageResponse } from '../../shared/types';15+16+// ── chrome storage shim ───────────────────────────────────────────────────────17+18+function installChromeStorageMock(storage: Record<string, unknown>): void {19+  const chromeMock = {20+    storage: {21+      local: {22+        get: vi.fn(async (key?: string | string[]) => {23+          if (!key) return { ...storage };24+          if (typeof key === 'string') return { [key]: storage[key] };25+          return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});26+        }),27+        set: vi.fn(async (entries: Record<string, unknown>) => { Object.assign(storage, entries); }),28+        remove: vi.fn(async (k: string | string[]) => {29+          const keys = Array.isArray(k) ? k : [k];30+          for (const key of keys) delete storage[key];31+        }),32+      },33+    },34+    runtime: {},35+  };36+  // @ts-expect-error test-only injection37+  globalThis.chrome = chromeMock;38+}39+40+// ── compression pipeline ──────────────────────────────────────────────────────41+42+describe('compression pipeline', () => {43+  it('deflates and inflates correctly', () => {44+    const original = 'peach codex test payload ' + 'x'.repeat(1000);45+    const bytes = new TextEncoder().encode(original);46+    const compressed = deflate(bytes, { level: 9 });47+    const decompressed = inflate(compressed);48+    const restored = new TextDecoder().decode(decompressed);49+    expect(restored).toBe(original);50+  });51+52+  it('compressed data is smaller than original for repetitive content', () => {53+    const repetitive = 'password123 '.repeat(500);54+    const bytes = new TextEncoder().encode(repetitive);55+    const compressed = deflate(bytes, { level: 9 });56+    expect(compressed.length).toBeLessThan(bytes.length);57+  });58+59+  it('compression round-trip handles empty payload', () => {60+    const bytes = new TextEncoder().encode('{}');61+    const compressed = deflate(bytes);62+    const decompressed = inflate(compressed);63+    expect(new TextDecoder().decode(decompressed)).toBe('{}');64+  });65+66+  it('compression round-trip handles unicode', () => {67+    const unicode = '日本語テスト 🔑 vault data';68+    const bytes = new TextEncoder().encode(unicode);69+    const compressed = deflate(bytes);70+    const decompressed = inflate(compressed);71+    expect(new TextDecoder().decode(decompressed)).toBe(unicode);72+  });73+});74+75+// ── Reed-Solomon erasure coding ───────────────────────────────────────────────76+77+describe('Reed-Solomon erasure coding', () => {78+  it('encodes without error', async () => {79+    const data = crypto.getRandomValues(new Uint8Array(10_000));80+    const { dataChunks, parityChunks } = await encodeWithErasure(data);81+    expect(dataChunks.length).toBeGreaterThan(0);82+    expect(parityChunks.length).toBe(2);83+  });84+85+  it('reconstructs from all chunks', async () => {86+    const original = crypto.getRandomValues(new Uint8Array(10_000));87+    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);88+    const allChunks = new Map<number, Uint8Array>();89+    dataChunks.forEach((c, i) => allChunks.set(i, c));90+    parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c));91+    const reconstructed = await decodeWithErasure(allChunks, dataShards, totalChunks);92+    expect(reconstructed.slice(0, original.length)).toEqual(original);93+  });94+95+  it('reconstructs after losing exactly 1 chunk', async () => {96+    const original = crypto.getRandomValues(new Uint8Array(20_000));97+    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);98+    const allChunks = new Map<number, Uint8Array>();99+    dataChunks.forEach((c, i) => allChunks.set(i, c));100+    parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c));101+    allChunks.delete(0);102+    const reconstructed = await decodeWithErasure(allChunks, dataShards, totalChunks);103+    expect(reconstructed.slice(0, original.length)).toEqual(original);104+  });105+106+  it('reconstructs after losing exactly 2 chunks', async () => {107+    const original = crypto.getRandomValues(new Uint8Array(20_000));108+    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);109+    const allChunks = new Map<number, Uint8Array>();110+    dataChunks.forEach((c, i) => allChunks.set(i, c));111+    parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c));112+    const indices = Array.from(allChunks.keys());113+    allChunks.delete(indices[0]!);114+    allChunks.delete(indices[Math.floor(indices.length / 2)]!);115+    const reconstructed = await decodeWithErasure(allChunks, dataShards, totalChunks);116+    expect(reconstructed.slice(0, original.length)).toEqual(original);117+  });118+119+  it('throws CodexError when too many chunks missing', async () => {120+    const original = crypto.getRandomValues(new Uint8Array(10_000));121+    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);122+    const allChunks = new Map<number, Uint8Array>();123+    dataChunks.forEach((c, i) => allChunks.set(i, c));124+    parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c));125+    allChunks.delete(0);126+    allChunks.delete(1);127+    allChunks.delete(2);128+    await expect(decodeWithErasure(allChunks, dataShards, totalChunks)).rejects.toThrow(CodexError);129+  });130+});131+132+// ── chunk envelope integrity ──────────────────────────────────────────────────133+134+describe('chunk envelope integrity', () => {135+  it('builds chunk envelope with correct fields', async () => {136+    const bytes = new Uint8Array([1, 2, 3, 4, 5]);137+    const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234');138+    expect(envelope.v).toBe(2);139+    expect(envelope.i).toBe(0);140+    expect(envelope.t).toBe(10);141+    expect(envelope.p).toBe(false);142+    expect(envelope.c).toBe('PX-TEST-1234');143+    expect(envelope.d).toBeTruthy();144+    expect(envelope.h).toBeTruthy();145+    expect(envelope.h.length).toBe(8);146+  });147+148+  it('integrity check passes for valid envelope', async () => {149+    const bytes = new Uint8Array([1, 2, 3, 4, 5]);150+    const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234');151+    expect(await verifyChunkIntegrity(envelope)).toBe(true);152+  });153+154+  it('integrity check fails for tampered data', async () => {155+    const bytes = new Uint8Array([1, 2, 3, 4, 5]);156+    const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234');157+    const tampered = { ...envelope, d: envelope.d.slice(0, -2) + 'XX' };158+    expect(await verifyChunkIntegrity(tampered)).toBe(false);159+  });160+161+  it('integrity check fails for tampered hash', async () => {162+    const bytes = new Uint8Array([1, 2, 3, 4, 5]);163+    const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234');164+    const tampered = { ...envelope, h: 'XXXXXXXX' };165+    expect(await verifyChunkIntegrity(tampered)).toBe(false);166+  });167+168+  it('parity chunks are flagged correctly', async () => {169+    const bytes = new Uint8Array([1, 2, 3]);170+    const parity = await buildChunkEnvelope(bytes, 5, 7, true, 'PX-PARITY-00');171+    expect(parity.p).toBe(true);172+  });173+174+  it('data chunks are flagged correctly', async () => {175+    const bytes = new Uint8Array([1, 2, 3]);176+    const data = await buildChunkEnvelope(bytes, 2, 7, false, 'PX-DATA-001');177+    expect(data.p).toBe(false);178+  });179+});180+181+// ── verify code generation ────────────────────────────────────────────────────182+183+describe('verify code generation', () => {184+  let storage: Record<string, unknown>;185+186+  beforeEach(() => {187+    storage = {};188+    installChromeStorageMock(storage);189+  });190+191+  it('generates PX-XXXX-XXXX format', async () => {192+    const code = await getOrCreateCodexVerifyCode();193+    expect(code).toMatch(/^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/);194+  });195+196+  it('returns same code on subsequent calls', async () => {197+    const c1 = await getOrCreateCodexVerifyCode();198+    const c2 = await getOrCreateCodexVerifyCode();199+    expect(c1).toBe(c2);200+  });201+202+  it('uses unambiguous character set (no O, 0, I, 1)', async () => {203+    for (let i = 0; i < 20; i++) {204+      await clearVerifyCode();205+      const code = await getOrCreateCodexVerifyCode();206+      const inner = code.replace('PX-', '').replace('-', '');207+      expect(inner).not.toMatch(/[O01I]/);208+    }209+  });210+});211+212+// ── GET_CODEX_VAULT_DATA handler (via message dispatch) ───────────────────────213+214+type StorageShape = Record<string, unknown>;215+type MessageListener = (216+  msg: unknown,217+  sender: chrome.runtime.MessageSender,218+  sendResponse: (response: MessageResponse) => void219+) => boolean;220+221+describe('GET_CODEX_VAULT_DATA handler', () => {222+  let storage: StorageShape;223+  let sessionStorage: StorageShape;224+  let listener: MessageListener;225+226+  beforeEach(async () => {227+    vi.resetModules();228+    vi.useRealTimers();229+    storage = { peach_settings: { breachMonitoring: false } };230+    sessionStorage = {};231+232+    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));233+    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);234+235+    const chromeMock = {236+      runtime: {237+        id: 'peach-extension',238+        onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },239+        sendMessage: vi.fn(async () => ({ ok: true })),240+      },241+      tabs: {242+        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {243+          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }244+          return Promise.resolve([{ id: 1 }]);245+        }),246+        sendMessage: vi.fn(async () => ({ ok: true })),247+      },248+      storage: {249+        local: {250+          get: vi.fn(async (key?: string | string[]) => {251+            if (!key) return storage;252+            if (typeof key === 'string') return { [key]: storage[key] };253+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});254+          }),255+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),256+          remove: vi.fn(async (keys: string | string[]) => {257+            const list = Array.isArray(keys) ? keys : [keys];258+            for (const key of list) delete storage[key];259+          }),260+        },261+        session: {262+          get: vi.fn(async (key?: string | string[]) => {263+            if (!key) return sessionStorage;264+            if (typeof key === 'string') return { [key]: sessionStorage[key] };265+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});266+          }),267+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),268+          remove: vi.fn(async (keys: string | string[]) => {269+            const list = Array.isArray(keys) ? keys : [keys];270+            for (const key of list) delete sessionStorage[key];271+          }),272+          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),273+        },274+      },275+      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },276+      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },277+      scripting: { executeScript: vi.fn(async () => []) },278+    } as unknown as typeof chrome;279+280+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;281+282+    const messages = await import('../../background/messages');283+    messages.setupMessageHandler();284+    const vault = await import('../../background/vault');285+    await vault.ensureSettingsLoaded();286+  });287+288+  async function dispatch(message: Message): Promise<MessageResponse> {289+    return new Promise<MessageResponse>((resolve) => {290+      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);291+      expect(keepOpen).toBe(true);292+    });293+  }294+295+  async function mockPro(): Promise<void> {296+    const license = await import('../../background/license');297+    vi.spyOn(license, 'isProUser').mockResolvedValue(true);298+  }299+300+  async function mockNoPro(): Promise<void> {301+    const license = await import('../../background/license');302+    vi.spyOn(license, 'isProUser').mockResolvedValue(false);303+  }304+305+  it('is Pro-gated', async () => {306+    await mockNoPro();307+    const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });308+    expect(result.ok).toBe(false);309+    expect((result as { error: string }).error).toMatch(/pro_required/);310+  });311+312+  it('fails when vault is locked', async () => {313+    await mockPro();314+    const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });315+    expect(result.ok).toBe(false);316+    expect((result as { error: string }).error).toMatch(/VAULT_LOCKED|vault_locked/);317+  });318+319+  it('succeeds with Pro and unlocked vault', async () => {320+    await mockPro();321+    const vault = await import('../../background/vault');322+    await vault.createVault('test-pass-12345');323+    const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });324+    expect(result.ok).toBe(true);325+    expect((result as { data: Record<string, unknown> }).data).toBeTruthy();326+  });327+328+  it('snapshot includes entryCount', async () => {329+    await mockPro();330+    const vault = await import('../../background/vault');331+    await vault.createVault('test-pass-12345');332+    await dispatch({333+      type: 'ADD_ENTRY',334+      payload: { title: 'GitHub', username: 'alice', password: 'pw1', url: '' },335+    });336+    const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });337+    const data = (result as { data: { entryCount: number } }).data;338+    expect(data.entryCount).toBe(1);339+  });340+341+  it('snapshot includes verifyCode in PX-XXXX-XXXX format', async () => {342+    await mockPro();343+    const vault = await import('../../background/vault');344+    await vault.createVault('test-pass-12345');345+    const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });346+    const data = (result as { data: { verifyCode: string } }).data;347+    expect(data.verifyCode).toMatch(/^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/);348+  });349+});350+351+// ── ESTIMATE_CODEX_PAGES handler ──────────────────────────────────────────────352+353+describe('ESTIMATE_CODEX_PAGES handler', () => {354+  let storage: StorageShape;355+  let sessionStorage: StorageShape;356+  let listener: MessageListener;357+358+  beforeEach(async () => {359+    vi.resetModules();360+    vi.useRealTimers();361+    storage = { peach_settings: { breachMonitoring: false } };362+    sessionStorage = {};363+364+    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));365+    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);366+367+    const chromeMock = {368+      runtime: {369+        id: 'peach-extension',370+        onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },371+        sendMessage: vi.fn(async () => ({ ok: true })),372+      },373+      tabs: {374+        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {375+          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }376+          return Promise.resolve([{ id: 1 }]);377+        }),378+        sendMessage: vi.fn(async () => ({ ok: true })),379+      },380+      storage: {381+        local: {382+          get: vi.fn(async (key?: string | string[]) => {383+            if (!key) return storage;384+            if (typeof key === 'string') return { [key]: storage[key] };385+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});386+          }),387+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),388+          remove: vi.fn(async (keys: string | string[]) => {389+            const list = Array.isArray(keys) ? keys : [keys];390+            for (const key of list) delete storage[key];391+          }),392+        },393+        session: {394+          get: vi.fn(async (key?: string | string[]) => {395+            if (!key) return sessionStorage;396+            if (typeof key === 'string') return { [key]: sessionStorage[key] };397+            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});398+          }),399+          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),400+          remove: vi.fn(async (keys: string | string[]) => {401+            const list = Array.isArray(keys) ? keys : [keys];402+            for (const key of list) delete sessionStorage[key];403+          }),404+          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),405+        },406+      },407+      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },408+      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },409+      scripting: { executeScript: vi.fn(async () => []) },410+    } as unknown as typeof chrome;411+412+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;413+414+    const messages = await import('../../background/messages');415+    messages.setupMessageHandler();416+    const vault = await import('../../background/vault');417+    await vault.ensureSettingsLoaded();418+  });419+420+  async function dispatch(message: Message): Promise<MessageResponse> {421+    return new Promise<MessageResponse>((resolve) => {422+      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);423+      expect(keepOpen).toBe(true);424+    });425+  }426+427+  async function mockPro(): Promise<void> {428+    const license = await import('../../background/license');429+    vi.spyOn(license, 'isProUser').mockResolvedValue(true);430+  }431+432+  async function mockNoPro(): Promise<void> {433+    const license = await import('../../background/license');434+    vi.spyOn(license, 'isProUser').mockResolvedValue(false);435+  }436+437+  it('is Pro-gated', async () => {438+    await mockNoPro();439+    const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' });440+    expect(result.ok).toBe(false);441+    expect((result as { error: string }).error).toMatch(/pro_required/);442+  });443+444+  it('returns at least 1 page with an unlocked vault', async () => {445+    await mockPro();446+    const vault = await import('../../background/vault');447+    await vault.createVault('test-pass-12345');448+    const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' });449+    expect(result.ok).toBe(true);450+    expect((result as unknown as { pages: number }).pages).toBeGreaterThanOrEqual(1);451+  });452+453+  it('returns a page count (numeric) for any vault', async () => {454+    await mockPro();455+    const vault = await import('../../background/vault');456+    await vault.createVault('test-pass-12345');457+    const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' });458+    expect(typeof (result as unknown as { pages: number }).pages).toBe('number');459+  });460+});

view file

src/popup/codex/helpers.ts+6 −0
@@ -165,3 +165,9 @@165165   }166166   await chrome.storage.local.set({ [STORAGE_KEY_VERIFY_CODE]: code });167167 }168+169+/** Removes the stored verify code so the next call to getOrCreateCodexVerifyCode generates a fresh one. */170+export async function clearVerifyCode(): Promise<void> {171+  if (typeof chrome === "undefined" || !chrome.storage?.local) return;172+  await chrome.storage.local.remove(STORAGE_KEY_VERIFY_CODE);173+}

view file

src/popup/components/GeneratorDrawer.test.ts+196 −0
@@ -0,0 +1,196 @@1+import { describe, it, expect } from 'vitest';2+import { initFromPolicy, initGeneratorState, handleLengthChange } from '../generator-policy';3+4+describe('generator site policy constraints', () => {5+6+  describe('Case 1 — no policy', () => {7+    it('defaults to 64 when unconstrained and fresh', () => {8+      const r = initFromPolicy(null, { currentLength: 64, userHasAdjusted: false });9+      expect(r.length).toBe(64);10+    });11+12+    it('preserves user length when no policy and user adjusted', () => {13+      const r = initFromPolicy(null, { currentLength: 80, userHasAdjusted: true });14+      expect(r.length).toBe(80);15+    });16+17+    it('shows no constraint note', () => {18+      const r = initFromPolicy(null, { currentLength: 64, userHasAdjusted: false });19+      expect(r.constraintNote).toBeFalsy();20+    });21+22+    it('leaves slider bounds at defaults', () => {23+      const r = initFromPolicy(null, { currentLength: 64, userHasAdjusted: false });24+      expect(r.sliderMin).toBe(8);25+      expect(r.sliderMax).toBe(128);26+    });27+  });28+29+  describe('Case 2 — site maxLength, fresh open', () => {30+    it('snaps to site maxLength', () => {31+      const r = initFromPolicy(32+        { maxLength: 100 },33+        { currentLength: 64, userHasAdjusted: false },34+      );35+      expect(r.length).toBe(100);36+    });37+38+    it('shows constraint note containing maxLength', () => {39+      const r = initFromPolicy(40+        { maxLength: 100 },41+        { currentLength: 64, userHasAdjusted: false },42+      );43+      expect(r.constraintNote).toMatch(/100/);44+    });45+46+    it('sets sliderMax to site maxLength', () => {47+      const r = initFromPolicy(48+        { maxLength: 100 },49+        { currentLength: 64, userHasAdjusted: false },50+      );51+      expect(r.sliderMax).toBe(100);52+    });53+  });54+55+  describe('Case 3 — user above site max', () => {56+    it('snaps DOWN to site max', () => {57+      const r = initFromPolicy(58+        { maxLength: 50 },59+        { currentLength: 80, userHasAdjusted: true },60+      );61+      expect(r.length).toBe(50);62+    });63+64+    it('shows constraint note after forced snap down', () => {65+      const r = initFromPolicy(66+        { maxLength: 50 },67+        { currentLength: 80, userHasAdjusted: true },68+      );69+      expect(r.constraintNote).toBeTruthy();70+    });71+  });72+73+  describe('Case 4 — user below site max', () => {74+    it('keeps user length when within bounds', () => {75+      const r = initFromPolicy(76+        { maxLength: 100 },77+        { currentLength: 20, userHasAdjusted: true },78+      );79+      expect(r.length).toBe(20);80+    });81+82+    it('shows no constraint note when within bounds', () => {83+      const r = initFromPolicy(84+        { maxLength: 100 },85+        { currentLength: 20, userHasAdjusted: true },86+      );87+      expect(r.constraintNote).toBeFalsy();88+    });89+  });90+91+  describe('Case 5 — site minLength', () => {92+    it('snaps UP to site minLength', () => {93+      const r = initFromPolicy(94+        { minLength: 12 },95+        { currentLength: 8, userHasAdjusted: false },96+      );97+      expect(r.length).toBe(12);98+    });99+100+    it('shows constraint note containing minLength', () => {101+      const r = initFromPolicy(102+        { minLength: 12 },103+        { currentLength: 8, userHasAdjusted: false },104+      );105+      expect(r.constraintNote).toMatch(/12/);106+    });107+108+    it('sets sliderMin to site minLength', () => {109+      const r = initFromPolicy(110+        { minLength: 12 },111+        { currentLength: 8, userHasAdjusted: false },112+      );113+      expect(r.sliderMin).toBe(12);114+    });115+116+    it('does not snap up when already above minLength', () => {117+      const r = initFromPolicy(118+        { minLength: 12 },119+        { currentLength: 20, userHasAdjusted: true },120+      );121+      expect(r.length).toBe(20);122+    });123+  });124+125+  describe('Case 6 — both min and max', () => {126+    it('snaps to min when below range', () => {127+      const r = initFromPolicy(128+        { minLength: 8, maxLength: 32 },129+        { currentLength: 4, userHasAdjusted: false },130+      );131+      expect(r.length).toBe(8);132+    });133+134+    it('snaps to max when above range', () => {135+      const r = initFromPolicy(136+        { minLength: 8, maxLength: 32 },137+        { currentLength: 64, userHasAdjusted: true },138+      );139+      expect(r.length).toBe(32);140+    });141+142+    it('keeps user length when within range', () => {143+      const r = initFromPolicy(144+        { minLength: 8, maxLength: 32 },145+        { currentLength: 20, userHasAdjusted: true },146+      );147+      expect(r.length).toBe(20);148+    });149+150+    it('defaults to maxLength on fresh open', () => {151+      const r = initFromPolicy(152+        { minLength: 8, maxLength: 32 },153+        { currentLength: 64, userHasAdjusted: false },154+      );155+      expect(r.length).toBe(32);156+    });157+158+    it('sets both sliderMin and sliderMax', () => {159+      const r = initFromPolicy(160+        { minLength: 8, maxLength: 32 },161+        { currentLength: 20, userHasAdjusted: false },162+      );163+      expect(r.sliderMin).toBe(8);164+      expect(r.sliderMax).toBe(32);165+    });166+  });167+168+  describe('userHasAdjusted flag', () => {169+    it('is false on fresh generator open', () => {170+      const state = initGeneratorState();171+      expect(state.userHasAdjusted).toBe(false);172+    });173+174+    it('becomes true after slider move', () => {175+      let state = initGeneratorState();176+      state = handleLengthChange(state, 80);177+      expect(state.userHasAdjusted).toBe(true);178+    });179+180+    it('false: policy snap applies', () => {181+      const r = initFromPolicy(182+        { maxLength: 100 },183+        { currentLength: 64, userHasAdjusted: false },184+      );185+      expect(r.length).toBe(100);186+    });187+188+    it('true: policy only enforces hard bounds', () => {189+      const r = initFromPolicy(190+        { maxLength: 100 },191+        { currentLength: 30, userHasAdjusted: true },192+      );193+      expect(r.length).toBe(30);194+    });195+  });196+});

view file

src/popup/generator-policy.ts+64 −0
@@ -0,0 +1,64 @@1+import type { PasswordPolicy } from "../shared/types";2+3+export interface GeneratorState {4+  currentLength: number;5+  userHasAdjusted: boolean;6+}7+8+export interface PolicyResult {9+  length: number;10+  sliderMin: number;11+  sliderMax: number;12+  constraintNote: string;13+}14+15+const DEFAULT_LENGTH = 64;16+const DEFAULT_SLIDER_MIN = 8;17+const DEFAULT_SLIDER_MAX = 128;18+19+export function initGeneratorState(): GeneratorState {20+  return { currentLength: DEFAULT_LENGTH, userHasAdjusted: false };21+}22+23+export function handleLengthChange(state: GeneratorState, length: number): GeneratorState {24+  return { ...state, currentLength: length, userHasAdjusted: true };25+}26+27+export function initFromPolicy(28+  policy: PasswordPolicy | null,29+  state: { currentLength: number; userHasAdjusted: boolean },30+): PolicyResult {31+  const sliderMin = policy?.minLength ? Math.max(1, policy.minLength) : DEFAULT_SLIDER_MIN;32+  const sliderMax = policy?.maxLength ? Math.min(DEFAULT_SLIDER_MAX, policy.maxLength) : DEFAULT_SLIDER_MAX;33+34+  let length = state.currentLength;35+36+  if (policy) {37+    if (policy.maxLength !== undefined && policy.minLength !== undefined) {38+      // Both bounds: always clamp to valid range, regardless of userHasAdjusted39+      length = Math.max(policy.minLength, Math.min(policy.maxLength, state.currentLength));40+    } else if (policy.maxLength !== undefined) {41+      if (!state.userHasAdjusted) {42+        // Fresh open: offer the strongest password the site accepts43+        length = policy.maxLength;44+      } else if (state.currentLength > policy.maxLength) {45+        // User above max: snap down46+        length = policy.maxLength;47+      }48+    } else if (policy.minLength !== undefined && state.currentLength < policy.minLength) {49+      // Below min: snap up50+      length = policy.minLength;51+    }52+  }53+54+  let constraintNote = "";55+  if (policy) {56+    if (policy.maxLength !== undefined && length >= policy.maxLength) {57+      constraintNote = `This site allows max ${policy.maxLength} characters`;58+    } else if (policy.minLength !== undefined && length <= policy.minLength) {59+      constraintNote = `This site requires at least ${policy.minLength} characters`;60+    }61+  }62+63+  return { length, sliderMin, sliderMax, constraintNote };64+}

view file

src/shared/types.ts+3 −0
@@ -313,6 +313,9 @@313313   | "TEST_SUBNET_DETECT"314314   | "SAVE_NOTE"315315   | "DELETE_NOTE"316+  | "GET_NOTES"317+  | "SEARCH_VAULT"318+  | "PAIRING_COMPLETED"316319   | "ML_INIT"317320   | "ML_PREDICT_BATCH"318321   // Content-script-bound message types — sent BY the background TO content

view file