1import { describe, it, expect, beforeEach } from 'vitest';2import { vi } from 'vitest';34import type { Message, MessageResponse, StoredVault } 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 function rawSlotA(): StoredVault {105 const slot = storage.peach_vault_a as StoredVault | undefined;106 if (!slot) throw new Error('missing slot A');107 return slot;108 }109110 async function mockNoPro(): Promise<void> {111 const license = await import('./license');112 vi.spyOn(license, 'isProUser').mockResolvedValue(false);113 }114115 describe('GET_RECOVERY_WORDS handler', () => {116 beforeEach(async () => { await setupUnlockedVault(); });117118 it('returns 12 words', async () => {119 const result = await dispatch({ type: 'GET_RECOVERY_WORDS' });120 expect(result.ok).toBe(true);121 const words = (result as { data: string[] }).data;122 expect(words).toHaveLength(12);123 });124125 it('all words are from the BIP39 word list', async () => {126 const result = await dispatch({ type: 'GET_RECOVERY_WORDS' });127 const words = (result as { data: string[] }).data;128 for (const word of words) {129 expect(WORD_LIST).toContain(word);130 }131 });132133 it('returns same words on subsequent calls (deterministic)', async () => {134 const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS' });135 const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS' });136 expect((r1 as { data: string[] }).data).toEqual((r2 as { data: string[] }).data);137 });138139 it('returns error when vault is locked', async () => {140 await lockVault();141 const result = await dispatch({ type: 'GET_RECOVERY_WORDS' });142 expect(result.ok).toBe(false);143 });144 });145146 describe('VALIDATE_RECOVERY_WORDS handler', () => {147 beforeEach(async () => { await setupUnlockedVault(); });148149 it('returns no invalid words for correct list', async () => {150 const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });151 const words = (r as { data: string[] }).data;152 const result = await dispatch({153 type: 'VALIDATE_RECOVERY_WORDS',154 payload: { words },155 });156 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;157 expect(data.invalid).toEqual([]);158 });159160 it('returns invalid words for unknown words', async () => {161 const result = await dispatch({162 type: 'VALIDATE_RECOVERY_WORDS',163 payload: { words: ['notaword', 'alsonotaword'] },164 });165 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;166 const invalidWords = data.invalid.map((x) => x.word);167 expect(invalidWords).toContain('notaword');168 expect(invalidWords).toContain('alsonotaword');169 });170171 it('is case-insensitive for validation', async () => {172 const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });173 const words = (r as { data: string[] }).data;174 const uppercased = words.map((w: string) => w.toUpperCase());175 const result = await dispatch({176 type: 'VALIDATE_RECOVERY_WORDS',177 payload: { words: uppercased },178 });179 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;180 expect(data.invalid).toEqual([]);181 });182 });183184 describe('RECOVER_WITH_WORDS handler', () => {185 it('recovery succeeds with correct words and new passphrase', async () => {186 await setupUnlockedVault();187 const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });188 const words = (r as { data: string[] }).data;189 await lockVault();190191 const result = await dispatch({192 type: 'RECOVER_WITH_WORDS',193 payload: { words, newPassword: 'new-passphrase' },194 });195 expect(result.ok).toBe(true);196 const unlocked = await dispatch({ type: 'IS_UNLOCKED' });197 expect((unlocked as { data: boolean }).data).toBe(true);198 });199200 it('fails with wrong recovery words', async () => {201 await setupUnlockedVault();202 await lockVault();203 const fakeWords = Array(12).fill('abandon');204 const result = await dispatch({205 type: 'RECOVER_WITH_WORDS',206 payload: { words: fakeWords, newPassword: 'new-pass' },207 });208 expect(result.ok).toBe(false);209 expect((result as { error: string }).error).toMatch(/invalid/);210 });211212 it('fails with partial word list (fewer than 12)', async () => {213 await setupUnlockedVault();214 const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });215 const words = (r as { data: string[] }).data;216 await lockVault();217 const partial = words.slice(0, 6);218 const result = await dispatch({219 type: 'RECOVER_WITH_WORDS',220 payload: { words: partial, newPassword: 'new-pass' },221 });222 expect(result.ok).toBe(false);223 });224225 it('new passphrase works after recovery', async () => {226 await setupUnlockedVault();227 const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });228 const words = (r as { data: string[] }).data;229 await lockVault();230 await dispatch({231 type: 'RECOVER_WITH_WORDS',232 payload: { words, newPassword: 'brand-new-password' },233 });234 await lockVault();235 const unlock = await dispatch({236 type: 'UNLOCK',237 payload: { password: 'brand-new-password' },238 });239 expect(unlock.ok).toBe(true);240 });241242 it('old passphrase does not work after recovery', async () => {243 await setupUnlockedVault();244 const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });245 const words = (r as { data: string[] }).data;246 await lockVault();247 await dispatch({248 type: 'RECOVER_WITH_WORDS',249 payload: { words, newPassword: 'brand-new-password' },250 });251 await lockVault();252 const unlock = await dispatch({253 type: 'UNLOCK',254 payload: { password: 'test-pass-12345' },255 });256 expect(unlock.ok).toBe(false);257 });258259 it('preserves recovery escrow fields after recovery password rewrap', async () => {260 await setupUnlockedVault();261 const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });262 const words = (r as { data: string[] }).data;263 const before = { ...rawSlotA() };264 await lockVault();265266 const result = await dispatch({267 type: 'RECOVER_WITH_WORDS',268 payload: { words, newPassword: 'new-passphrase' },269 });270 expect(result.ok).toBe(true);271272 const after = rawSlotA();273 expect(after.recovery_wrapped_root_key).toBe(before.recovery_wrapped_root_key);274 expect(after.recovery_nonce).toBe(before.recovery_nonce);275 expect(after.recovery_tag).toBe(before.recovery_tag);276 expect(after.recovery_salt).toBe(before.recovery_salt);277 expect(after.ciphertext).not.toBe(before.ciphertext);278 expect(after.salt).not.toBe(before.salt);279 });280281 it('same recovery words succeed on a second recovery', async () => {282 await setupUnlockedVault();283 const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });284 const words = (r as { data: string[] }).data;285286 await lockVault();287 const first = await dispatch({288 type: 'RECOVER_WITH_WORDS',289 payload: { words, newPassword: 'pass-1' },290 });291 expect(first.ok).toBe(true);292293 await lockVault();294 const second = await dispatch({295 type: 'RECOVER_WITH_WORDS',296 payload: { words, newPassword: 'pass-2' },297 });298 expect(second.ok).toBe(true);299300 await lockVault();301 const unlock = await dispatch({302 type: 'UNLOCK',303 payload: { password: 'pass-2' },304 });305 expect(unlock.ok).toBe(true);306 });307308 it('same recovery words succeed on a third recovery', async () => {309 await setupUnlockedVault();310 const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });311 const words = (r as { data: string[] }).data;312313 for (let i = 0; i < 3; i += 1) {314 await lockVault();315 const result = await dispatch({316 type: 'RECOVER_WITH_WORDS',317 payload: { words, newPassword: `pass-${i}` },318 });319 expect(result.ok).toBe(true);320 }321 });322323 it('vault remains usable after recovery', async () => {324 await setupUnlockedVault();325 const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });326 const words = (r as { data: string[] }).data;327328 await lockVault();329 const recovered = await dispatch({330 type: 'RECOVER_WITH_WORDS',331 payload: { words, newPassword: 'new-passphrase' },332 });333 expect(recovered.ok).toBe(true);334335 await dispatch({336 type: 'ADD_ENTRY',337 payload: { title: 'Check me', username: '', password: 'p', url: '' },338 });339 const entries = (await dispatch({ type: 'GET_ENTRIES' }) as { data: Array<{ title: string }> }).data;340 expect(entries.some((entry) => entry.title === 'Check me')).toBe(true);341 });342343 it('fresh recovery words are generated after password change', async () => {344 await setupUnlockedVault();345 const before = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;346 await dispatch({347 type: 'CHANGE_PASSWORD',348 payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-67890' },349 });350 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;351 expect(after).not.toEqual(before);352 });353354 it('recovery is not Pro-gated', async () => {355 await setupUnlockedVault();356 await mockNoPro();357 const r = await dispatch({ type: 'GET_RECOVERY_WORDS' });358 const words = (r as { data: string[] }).data;359 await lockVault();360 const result = await dispatch({361 type: 'RECOVER_WITH_WORDS',362 payload: { words, newPassword: 'new-pass' },363 });364 expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);365 });366 });367368 369 370 371 describe('deriveRootKeyFromDerivedKey and recovery persistence', () => {372 it('GET_RECOVERY_WORDS works after lock and re-unlock', async () => {373 await setupUnlockedVault();374 const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS' });375 const words1 = (r1 as { data: string[] }).data;376 expect(words1).toHaveLength(12);377 await lockVault();378 const unlocked = await dispatch({379 type: 'UNLOCK',380 payload: { password: 'test-pass-12345' },381 });382 expect(unlocked.ok).toBe(true);383 const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS' });384 const words2 = (r2 as { data: string[] }).data;385 expect(words2).toEqual(words1);386 });387388 it('GET_RECOVERY_WORDS works after multiple lock/unlock cycles', async () => {389 await setupUnlockedVault();390 const original = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;391 for (let i = 0; i < 5; i++) {392 await lockVault();393 await dispatch({394 type: 'UNLOCK',395 payload: { password: 'test-pass-12345' },396 });397 }398 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS' }) as { data: string[] }).data;399 expect(after).toEqual(original);400 });401402 it('pre-v2.12 vault (no recovery escrow) gets recovery words after first unlock', async () => {403 404 405 await setupUnlockedVault();406 const slot = rawSlotA();407 const legacy: StoredVault = {408 version: slot.version,409 salt: slot.salt,410 nonce: slot.nonce,411 ciphertext: slot.ciphertext,412 tag: slot.tag,413 };414 storage.peach_vault_a = legacy;415 await lockVault();416417 const unlocked = await dispatch({418 type: 'UNLOCK',419 payload: { password: 'test-pass-12345' },420 });421 expect(unlocked.ok).toBe(true);422423 const result = await dispatch({ type: 'GET_RECOVERY_WORDS' });424 expect(result.ok).toBe(true);425 const words = (result as { data: string[] }).data;426 expect(words).toHaveLength(12);427428 429 430 expect(storage.peach_recovery_pending).toBe('1');431432 433 const slotAfter = storage.peach_vault_a as StoredVault;434 expect(slotAfter.recovery_salt).toBeTruthy();435 expect(slotAfter.recovery_wrapped_root_key).toBeTruthy();436 expect(slotAfter.pw_root_ciphertext).toBeTruthy();437 });438 });439});