import { describe, it, expect, beforeEach } from 'vitest'; import { vi } from 'vitest'; import type { Message, MessageResponse } from '../shared/types'; type StorageShape = Record; type MessageListener = ( msg: unknown, sender: chrome.runtime.MessageSender, sendResponse: (response: MessageResponse) => void ) => boolean; describe('notes message handlers', () => { let storage: StorageShape; let sessionStorage: StorageShape; let listener: MessageListener; beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); storage = { peach_settings: { breachMonitoring: false } }; sessionStorage = {}; vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' }))); vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false); const chromeMock = { runtime: { id: 'peach-extension', onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }), }, sendMessage: vi.fn(async () => ({ ok: true })), }, tabs: { query: vi.fn((_, cb?: (tabs: unknown[]) => void) => { if (typeof cb === 'function') { cb([{ id: 1 }]); return; } return Promise.resolve([{ id: 1 }]); }), sendMessage: vi.fn(async () => ({ ok: true })), }, storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === 'string') return { [key]: storage[key] }; return key.reduce>((acc, k) => { acc[k] = storage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete storage[key]; }), }, session: { get: vi.fn(async (key?: string | string[]) => { if (!key) return sessionStorage; if (typeof key === 'string') return { [key]: sessionStorage[key] }; return key.reduce>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(sessionStorage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete sessionStorage[key]; }), clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, scripting: { executeScript: vi.fn(async () => []) }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; const messages = await import('./messages'); messages.setupMessageHandler(); const vault = await import('./vault'); await vault.ensureSettingsLoaded(); }); async function dispatch(message: Message): Promise { return new Promise((resolve) => { const keepOpen = listener(message, { id: 'peach-extension' }, resolve); expect(keepOpen).toBe(true); }); } async function setupVault(): Promise { const vault = await import('./vault'); await vault.createVault('test-pass-12345'); } async function lockVault(): Promise { const vault = await import('./vault'); vault.lock(); } describe('SAVE_NOTE handler', () => { beforeEach(async () => { await setupVault(); }); it('creates a new note entry with type="note"', async () => { const result = await dispatch({ type: 'SAVE_NOTE', payload: { title: 'Test note', body: 'Hello world' }, }); expect(result.ok).toBe(true); const note = (result as { data: Record }).data; expect(note.type).toBe('note'); expect(note.title).toBe('Test note'); expect(note.id).toBeTruthy(); }); it('assigns createdAt and modifiedAt timestamps', async () => { const before = Date.now(); const result = await dispatch({ type: 'SAVE_NOTE', payload: { title: 'Timestamps', body: '' }, }); const after = Date.now(); const note = (result as { data: Record }).data; expect(note.createdAt as number).toBeGreaterThanOrEqual(before); expect(note.createdAt as number).toBeLessThanOrEqual(after); expect(note.modifiedAt as number).toBeGreaterThanOrEqual(before); }); it('updates existing note by id', async () => { const created = await dispatch({ type: 'SAVE_NOTE', payload: { title: 'Original', body: 'First' }, }); const createdNote = (created as { data: Record }).data; const updated = await dispatch({ type: 'SAVE_NOTE', payload: { id: createdNote.id as string, title: 'Updated', body: 'Second' }, }); expect(updated.ok).toBe(true); const updatedNote = (updated as { data: Record }).data; expect(updatedNote.id).toBe(createdNote.id); expect(updatedNote.title).toBe('Updated'); expect(updatedNote.modifiedAt as number).toBeGreaterThanOrEqual(createdNote.modifiedAt as number); }); it('does not change createdAt on update', async () => { const created = await dispatch({ type: 'SAVE_NOTE', payload: { title: 'Original', body: '' }, }); const createdNote = (created as { data: Record }).data; await new Promise(r => setTimeout(r, 10)); const updated = await dispatch({ type: 'SAVE_NOTE', payload: { id: createdNote.id as string, title: 'Updated', body: '' }, }); const updatedNote = (updated as { data: Record }).data; expect(updatedNote.createdAt).toBe(createdNote.createdAt); }); it('returns error when vault is locked', async () => { await lockVault(); const result = await dispatch({ type: 'SAVE_NOTE', payload: { title: 'Test', body: '' }, }); expect(result.ok).toBe(false); expect((result as { error: string }).error).toMatch(/vault_locked/); }); it('returns error when updating nonexistent id', async () => { const result = await dispatch({ type: 'SAVE_NOTE', payload: { id: 'nonexistent-id', title: 'Test', body: '' }, }); expect(result.ok).toBe(false); expect((result as { error: string }).error).toMatch(/not_found/); }); }); describe('DELETE_NOTE handler', () => { it('soft-deletes a note and removes it from GET_NOTES results', async () => { await setupVault(); const created = await dispatch({ type: 'SAVE_NOTE', payload: { title: 'To delete', body: '' }, }); const note = (created as { data: Record }).data; const deleted = await dispatch({ type: 'DELETE_NOTE', payload: { id: note.id as string }, }); expect(deleted.ok).toBe(true); const notes = await dispatch({ type: 'GET_NOTES' }); const notesList = (notes as unknown as { data: Array> }).data; expect(notesList.find((n) => n.id === note.id)).toBeUndefined(); }); it('returns error when vault is locked', async () => { await setupVault(); await lockVault(); const result = await dispatch({ type: 'DELETE_NOTE', payload: { id: 'some-id' }, }); expect(result.ok).toBe(false); }); }); describe('notes in vault entries', () => { it('note entries do not appear in autofill results', async () => { await setupVault(); await dispatch({ type: 'SAVE_NOTE', payload: { title: 'github.com', body: 'Some secret notes' }, }); const result = await dispatch({ type: 'AUTOFILL_QUERY', payload: { domain: 'github.com' }, }); const data = (result as { data: Array> }).data; const hasNote = data?.some((e) => e.type === 'note'); expect(hasNote).toBeFalsy(); }); it('note entries appear in vault search', async () => { await setupVault(); await dispatch({ type: 'SAVE_NOTE', payload: { title: 'SSH passphrase', body: 'my server key' }, }); const result = await dispatch({ type: 'SEARCH_VAULT', payload: { query: 'SSH' }, }); const entries = (result as unknown as { data: Array> }).data; const found = entries?.some((e) => e.type === 'note' && e.title === 'SSH passphrase'); expect(found).toBe(true); }); it('note body is searched', async () => { await setupVault(); await dispatch({ type: 'SAVE_NOTE', payload: { title: 'Random title', body: 'backup codes ABCD1234' }, }); const result = await dispatch({ type: 'SEARCH_VAULT', payload: { query: 'ABCD1234' }, }); const entries = (result as unknown as { data: Array }).data; expect(entries?.length).toBeGreaterThan(0); }); }); });