recovery-words.test.ts

10.1 KB · raw

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});