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
↓ peach-browser-15de0ce0.tar.gzatlas at this save
files changed16 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 −0manifest.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": {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",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": {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);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+});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+});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+});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+});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+});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));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+});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+});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+}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+});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+}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