1import { describe, it, expect, beforeEach } from 'vitest';2import { vi } from 'vitest';34import type { Message, MessageResponse } from '../shared/types';5import { WORD_LIST } from '../shared/recovery-word-list';67type StorageShape = Record<string, unknown>;8type MessageListener = (9 msg: unknown,10 sender: chrome.runtime.MessageSender,11 sendResponse: (response: MessageResponse) => void12) => boolean;1314describe('recovery words handlers', () => {15 let storage: StorageShape;16 let sessionStorage: StorageShape;17 let listener: MessageListener;1819 beforeEach(async () => {20 vi.resetModules();21 vi.useRealTimers();22 storage = { peach_settings: { breachMonitoring: false } };23 sessionStorage = {};2425 vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));26 vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);2728 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;7879 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8081 const messages = await import('./messages');82 messages.setupMessageHandler();83 const vault = await import('./vault');84 await vault.ensureSettingsLoaded();85 });8687 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 }9394 async function setupUnlockedVault(): Promise<void> {95 const vault = await import('./vault');96 await vault.createVault('test-pass-12345');97 }9899 async function lockVault(): Promise<void> {100 const vault = await import('./vault');101 vault.lock();102 }103104 async function mockNoPro(): Promise<void> {105 const license = await import('./license');106 vi.spyOn(license, 'isProUser').mockResolvedValue(false);107 }108109 describe('GET_RECOVERY_WORDS handler', () => {110 beforeEach(async () => { await setupUnlockedVault(); });111112 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 });118119 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 });126127 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 });132133 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 });139140 describe('VALIDATE_RECOVERY_WORDS handler', () => {141 beforeEach(async () => { await setupUnlockedVault(); });142143 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 });153154 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 });164165 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 });177178 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();184185 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 });193194 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 });205206 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 });218219 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 });235236 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 });252253 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 });263264 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});