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';6789101112vi.setConfig({ testTimeout: 30_000 });1314type StorageShape = Record<string, unknown>;15type MessageListener = (16 msg: unknown,17 sender: chrome.runtime.MessageSender,18 sendResponse: (response: MessageResponse) => void19) => boolean;2021describe('recovery words handlers', () => {22 let storage: StorageShape;23 let sessionStorage: StorageShape;24 let listener: MessageListener;2526 beforeEach(async () => {27 vi.resetModules();28 vi.useRealTimers();29 storage = { peach_settings: { breachMonitoring: false } };30 sessionStorage = {};3132 vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));33 vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);3435 const chromeMock = {36 runtime: {37 id: 'peach-extension',38 getURL: (path = '') => `chrome-extension://peach-extension/${path}`,39 onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },40 sendMessage: vi.fn(async () => ({ ok: true })),41 },42 tabs: {43 query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {44 if (typeof cb === 'function') { cb([{ id: 1 }]); return; }45 return Promise.resolve([{ id: 1 }]);46 }),47 sendMessage: vi.fn(async () => ({ ok: true })),48 },49 storage: {50 local: {51 get: vi.fn(async (key?: string | string[]) => {52 if (!key) return storage;53 if (typeof key === 'string') return { [key]: storage[key] };54 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});55 }),56 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),57 remove: vi.fn(async (keys: string | string[]) => {58 const list = Array.isArray(keys) ? keys : [keys];59 for (const key of list) delete storage[key];60 }),61 },62 session: {63 get: vi.fn(async (key?: string | string[]) => {64 if (!key) return sessionStorage;65 if (typeof key === 'string') return { [key]: sessionStorage[key] };66 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});67 }),68 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),69 remove: vi.fn(async (keys: string | string[]) => {70 const list = Array.isArray(keys) ? keys : [keys];71 for (const key of list) delete sessionStorage[key];72 }),73 clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),74 },75 },76 alarms: {77 clear: vi.fn(), create: vi.fn(),78 onAlarm: { addListener: vi.fn() },79 },80 idle: {81 setDetectionInterval: vi.fn(),82 onStateChanged: { addListener: vi.fn() },83 },84 scripting: { executeScript: vi.fn(async () => []) },85 } as unknown as typeof chrome;8687 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8889 const messages = await import('./messages');90 messages.setupMessageHandler();91 const vault = await import('./vault');92 await vault.ensureSettingsLoaded();93 });9495 async function dispatch(message: Message): Promise<MessageResponse> {96 return new Promise<MessageResponse>((resolve) => {97 const keepOpen = listener(message, {98 id: 'peach-extension',99 url: 'chrome-extension://peach-extension/popup.html',100 }, resolve);101 expect(keepOpen).toBe(true);102 });103 }104105 async function setupUnlockedVault(): Promise<void> {106 const vault = await import('./vault');107 await vault.createVault('test-pass-12345');108 }109110 async function lockVault(): Promise<void> {111 const vault = await import('./vault');112 vault.lock();113 }114115 function rawSlotA(): StoredVault {116 const slot = storage.peach_vault_a as StoredVault | undefined;117 if (!slot) throw new Error('missing slot A');118 return slot;119 }120121 async function mockNoPro(): Promise<void> {122 const license = await import('./license');123 vi.spyOn(license, 'isProUser').mockResolvedValue(false);124 }125126 describe('GET_RECOVERY_WORDS handler', () => {127 beforeEach(async () => { await setupUnlockedVault(); });128129 it('returns 12 words', async () => {130 const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });131 expect(result.ok).toBe(true);132 const words = (result as { data: string[] }).data;133 expect(words).toHaveLength(12);134 });135136 it('all words are from the BIP39 word list', async () => {137 const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });138 const words = (result as { data: string[] }).data;139 for (const word of words) {140 expect(WORD_LIST).toContain(word);141 }142 });143144 it('returns same words on subsequent calls (deterministic)', async () => {145 const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });146 const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });147 expect((r1 as { data: string[] }).data).toEqual((r2 as { data: string[] }).data);148 });149150 it('returns error when vault is locked', async () => {151 await lockVault();152 const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });153 expect(result.ok).toBe(false);154 });155 });156157 describe('VALIDATE_RECOVERY_WORDS handler', () => {158 beforeEach(async () => { await setupUnlockedVault(); });159160 it('returns no invalid words for correct list', async () => {161 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });162 const words = (r as { data: string[] }).data;163 const result = await dispatch({164 type: 'VALIDATE_RECOVERY_WORDS',165 payload: { words },166 });167 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;168 expect(data.invalid).toEqual([]);169 });170171 it('returns invalid words for unknown words', async () => {172 const result = await dispatch({173 type: 'VALIDATE_RECOVERY_WORDS',174 payload: { words: ['notaword', 'alsonotaword'] },175 });176 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;177 const invalidWords = data.invalid.map((x) => x.word);178 expect(invalidWords).toContain('notaword');179 expect(invalidWords).toContain('alsonotaword');180 });181182 it('is case-insensitive for validation', async () => {183 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });184 const words = (r as { data: string[] }).data;185 const uppercased = words.map((w: string) => w.toUpperCase());186 const result = await dispatch({187 type: 'VALIDATE_RECOVERY_WORDS',188 payload: { words: uppercased },189 });190 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;191 expect(data.invalid).toEqual([]);192 });193 });194195 describe('RECOVER_WITH_WORDS handler', () => {196 it('recovery succeeds with correct words and new passphrase', async () => {197 await setupUnlockedVault();198 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });199 const words = (r as { data: string[] }).data;200 await lockVault();201202 const result = await dispatch({203 type: 'RECOVER_WITH_WORDS',204 payload: { words, newPassword: 'new-passphrase' },205 });206 expect(result.ok).toBe(true);207 const unlocked = await dispatch({ type: 'IS_UNLOCKED' });208 expect((unlocked as { data: boolean }).data).toBe(true);209 });210211 it('fails with wrong recovery words', async () => {212 await setupUnlockedVault();213 await lockVault();214 const fakeWords = Array(12).fill('abandon');215 const result = await dispatch({216 type: 'RECOVER_WITH_WORDS',217 payload: { words: fakeWords, newPassword: 'new-pass' },218 });219 expect(result.ok).toBe(false);220 expect((result as { error: string }).error).toMatch(/invalid/);221 });222223 it('fails with partial word list (fewer than 12)', async () => {224 await setupUnlockedVault();225 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });226 const words = (r as { data: string[] }).data;227 await lockVault();228 const partial = words.slice(0, 6);229 const result = await dispatch({230 type: 'RECOVER_WITH_WORDS',231 payload: { words: partial, newPassword: 'new-pass' },232 });233 expect(result.ok).toBe(false);234 });235236 it('new passphrase works after recovery', async () => {237 await setupUnlockedVault();238 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });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: 'brand-new-password' },249 });250 expect(unlock.ok).toBe(true);251 });252253 it('old passphrase does not work after recovery', async () => {254 await setupUnlockedVault();255 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });256 const words = (r as { data: string[] }).data;257 await lockVault();258 await dispatch({259 type: 'RECOVER_WITH_WORDS',260 payload: { words, newPassword: 'brand-new-password' },261 });262 await lockVault();263 const unlock = await dispatch({264 type: 'UNLOCK',265 payload: { password: 'test-pass-12345' },266 });267 expect(unlock.ok).toBe(false);268 });269270 it('preserves recovery escrow fields after recovery password rewrap', async () => {271 await setupUnlockedVault();272 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });273 const words = (r as { data: string[] }).data;274 const before = { ...rawSlotA() };275 await lockVault();276277 const result = await dispatch({278 type: 'RECOVER_WITH_WORDS',279 payload: { words, newPassword: 'new-passphrase' },280 });281 expect(result.ok).toBe(true);282283 const after = rawSlotA();284 expect(after.recovery_wrapped_root_key).toBe(before.recovery_wrapped_root_key);285 expect(after.recovery_nonce).toBe(before.recovery_nonce);286 expect(after.recovery_tag).toBe(before.recovery_tag);287 expect(after.recovery_salt).toBe(before.recovery_salt);288 expect(after.ciphertext).not.toBe(before.ciphertext);289 expect(after.salt).not.toBe(before.salt);290 });291292 it('same recovery words succeed on a second recovery', async () => {293 await setupUnlockedVault();294 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });295 const words = (r as { data: string[] }).data;296297 await lockVault();298 const first = await dispatch({299 type: 'RECOVER_WITH_WORDS',300 payload: { words, newPassword: 'pass-1' },301 });302 expect(first.ok).toBe(true);303304 await lockVault();305 const second = await dispatch({306 type: 'RECOVER_WITH_WORDS',307 payload: { words, newPassword: 'pass-2' },308 });309 expect(second.ok).toBe(true);310311 await lockVault();312 const unlock = await dispatch({313 type: 'UNLOCK',314 payload: { password: 'pass-2' },315 });316 expect(unlock.ok).toBe(true);317 });318319 it('same recovery words succeed on a third recovery', async () => {320 await setupUnlockedVault();321 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });322 const words = (r as { data: string[] }).data;323324 for (let i = 0; i < 3; i += 1) {325 await lockVault();326 const result = await dispatch({327 type: 'RECOVER_WITH_WORDS',328 payload: { words, newPassword: `pass-${i}` },329 });330 expect(result.ok).toBe(true);331 }332 });333334 it('vault remains usable after recovery', async () => {335 await setupUnlockedVault();336 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });337 const words = (r as { data: string[] }).data;338339 await lockVault();340 const recovered = await dispatch({341 type: 'RECOVER_WITH_WORDS',342 payload: { words, newPassword: 'new-passphrase' },343 });344 expect(recovered.ok).toBe(true);345346 await dispatch({347 type: 'ADD_ENTRY',348 payload: { title: 'Check me', username: '', password: 'p', url: '' },349 });350 const entries = (await dispatch({ type: 'GET_ENTRIES' }) as { data: Array<{ title: string }> }).data;351 expect(entries.some((entry) => entry.title === 'Check me')).toBe(true);352 });353354 355 356 357 it('recovery words are preserved across CHANGE_PASSWORD', async () => {358 await setupUnlockedVault();359 const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;360 await dispatch({361 type: 'CHANGE_PASSWORD',362 payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-67890' },363 });364 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'new-pass-67890' } }) as { data: string[] }).data;365 expect(after).toEqual(before);366 });367368 it('recovery is not Pro-gated', async () => {369 await setupUnlockedVault();370 await mockNoPro();371 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });372 const words = (r as { data: string[] }).data;373 await lockVault();374 const result = await dispatch({375 type: 'RECOVER_WITH_WORDS',376 payload: { words, newPassword: 'new-pass' },377 });378 expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);379 });380 });381382 383 384 385 describe('changePassword preserves root key and recovery words', () => {386 it('recovery words work to unlock after password change', async () => {387 await setupUnlockedVault();388 const words = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;389 const ch = await dispatch({390 type: 'CHANGE_PASSWORD',391 payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-aaa' },392 });393 expect(ch.ok).toBe(true);394 await lockVault();395 const r = await dispatch({396 type: 'RECOVER_WITH_WORDS',397 payload: { words, newPassword: 'recovered-pass' },398 });399 expect(r.ok).toBe(true);400 });401402 it('recovery words are identical before and after password change', async () => {403 await setupUnlockedVault();404 const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;405 await dispatch({406 type: 'CHANGE_PASSWORD',407 payload: { currentPassword: 'test-pass-12345', newPassword: 'another-pass-bbb' },408 });409 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'another-pass-bbb' } }) as { data: string[] }).data;410 expect(after).toEqual(before);411 });412413 it('vault unlocks with the new password after change', async () => {414 await setupUnlockedVault();415 await dispatch({416 type: 'CHANGE_PASSWORD',417 payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' },418 });419 await lockVault();420 const r = await dispatch({421 type: 'UNLOCK',422 payload: { password: 'brand-new-pass' },423 });424 expect(r.ok).toBe(true);425 });426427 it('the old password no longer unlocks after change', async () => {428 await setupUnlockedVault();429 await dispatch({430 type: 'CHANGE_PASSWORD',431 payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' },432 });433 await lockVault();434 const r = await dispatch({435 type: 'UNLOCK',436 payload: { password: 'test-pass-12345' },437 });438 expect(r.ok).toBe(false);439 });440441 it('GET_RECOVERY_WORDS still works after a password change + lock + unlock', async () => {442 await setupUnlockedVault();443 const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;444 await dispatch({445 type: 'CHANGE_PASSWORD',446 payload: { currentPassword: 'test-pass-12345', newPassword: 'pass-after-cycle' },447 });448 await lockVault();449 await dispatch({450 type: 'UNLOCK',451 payload: { password: 'pass-after-cycle' },452 });453 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'pass-after-cycle' } }) as { data: string[] }).data;454 expect(after).toEqual(before);455 });456 });457458 459 460 461 describe('deriveRootKeyFromDerivedKey and recovery persistence', () => {462 it('GET_RECOVERY_WORDS works after lock and re-unlock', async () => {463 await setupUnlockedVault();464 const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });465 const words1 = (r1 as { data: string[] }).data;466 expect(words1).toHaveLength(12);467 await lockVault();468 const unlocked = await dispatch({469 type: 'UNLOCK',470 payload: { password: 'test-pass-12345' },471 });472 expect(unlocked.ok).toBe(true);473 const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });474 const words2 = (r2 as { data: string[] }).data;475 expect(words2).toEqual(words1);476 });477478 it('GET_RECOVERY_WORDS works after multiple lock/unlock cycles', async () => {479 await setupUnlockedVault();480 const original = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;481 for (let i = 0; i < 5; i++) {482 await lockVault();483 await dispatch({484 type: 'UNLOCK',485 payload: { password: 'test-pass-12345' },486 });487 }488 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;489 expect(after).toEqual(original);490 });491492 });493});