1import { describe, it, expect, beforeEach } from 'vitest';2import { vi } from 'vitest';34import type { Message, MessageResponse } from '../shared/types';56type StorageShape = Record<string, unknown>;7type MessageListener = (8 msg: unknown,9 sender: chrome.runtime.MessageSender,10 sendResponse: (response: MessageResponse) => void11) => boolean;1213describe('notes message handlers', () => {14 let storage: StorageShape;15 let sessionStorage: StorageShape;16 let listener: MessageListener;1718 beforeEach(async () => {19 vi.resetModules();20 vi.useRealTimers();21 storage = { peach_settings: { breachMonitoring: false } };22 sessionStorage = {};2324 vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));25 vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);2627 const chromeMock = {28 runtime: {29 id: 'peach-extension',30 onMessage: {31 addListener: vi.fn((fn: MessageListener) => { listener = fn; }),32 },33 sendMessage: vi.fn(async () => ({ ok: true })),34 },35 tabs: {36 query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {37 if (typeof cb === 'function') { cb([{ id: 1 }]); return; }38 return Promise.resolve([{ id: 1 }]);39 }),40 sendMessage: vi.fn(async () => ({ ok: true })),41 },42 storage: {43 local: {44 get: vi.fn(async (key?: string | string[]) => {45 if (!key) return storage;46 if (typeof key === 'string') return { [key]: storage[key] };47 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});48 }),49 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),50 remove: vi.fn(async (keys: string | string[]) => {51 const list = Array.isArray(keys) ? keys : [keys];52 for (const key of list) delete storage[key];53 }),54 },55 session: {56 get: vi.fn(async (key?: string | string[]) => {57 if (!key) return sessionStorage;58 if (typeof key === 'string') return { [key]: sessionStorage[key] };59 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});60 }),61 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),62 remove: vi.fn(async (keys: string | string[]) => {63 const list = Array.isArray(keys) ? keys : [keys];64 for (const key of list) delete sessionStorage[key];65 }),66 clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),67 },68 },69 alarms: {70 clear: vi.fn(), create: vi.fn(),71 onAlarm: { addListener: vi.fn() },72 },73 idle: {74 setDetectionInterval: vi.fn(),75 onStateChanged: { addListener: vi.fn() },76 },77 scripting: { executeScript: vi.fn(async () => []) },78 } as unknown as typeof chrome;7980 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8182 const messages = await import('./messages');83 messages.setupMessageHandler();84 const vault = await import('./vault');85 await vault.ensureSettingsLoaded();86 });8788 async function dispatch(message: Message): Promise<MessageResponse> {89 return new Promise<MessageResponse>((resolve) => {90 const keepOpen = listener(message, { id: 'peach-extension' }, resolve);91 expect(keepOpen).toBe(true);92 });93 }9495 async function setupVault(): Promise<void> {96 const vault = await import('./vault');97 await vault.createVault('test-pass-12345');98 }99100 async function lockVault(): Promise<void> {101 const vault = await import('./vault');102 vault.lock();103 }104105 describe('SAVE_NOTE handler', () => {106 beforeEach(async () => { await setupVault(); });107108 it('creates a new note entry with type="note"', async () => {109 const result = await dispatch({110 type: 'SAVE_NOTE',111 payload: { title: 'Test note', body: 'Hello world' },112 });113 expect(result.ok).toBe(true);114 const note = (result as { data: Record<string, unknown> }).data;115 expect(note.type).toBe('note');116 expect(note.title).toBe('Test note');117 expect(note.id).toBeTruthy();118 });119120 it('assigns createdAt and modifiedAt timestamps', async () => {121 const before = Date.now();122 const result = await dispatch({123 type: 'SAVE_NOTE',124 payload: { title: 'Timestamps', body: '' },125 });126 const after = Date.now();127 const note = (result as { data: Record<string, unknown> }).data;128 expect(note.createdAt as number).toBeGreaterThanOrEqual(before);129 expect(note.createdAt as number).toBeLessThanOrEqual(after);130 expect(note.modifiedAt as number).toBeGreaterThanOrEqual(before);131 });132133 it('updates existing note by id', async () => {134 const created = await dispatch({135 type: 'SAVE_NOTE',136 payload: { title: 'Original', body: 'First' },137 });138 const createdNote = (created as { data: Record<string, unknown> }).data;139 const updated = await dispatch({140 type: 'SAVE_NOTE',141 payload: { id: createdNote.id as string, title: 'Updated', body: 'Second' },142 });143 expect(updated.ok).toBe(true);144 const updatedNote = (updated as { data: Record<string, unknown> }).data;145 expect(updatedNote.id).toBe(createdNote.id);146 expect(updatedNote.title).toBe('Updated');147 expect(updatedNote.modifiedAt as number).toBeGreaterThanOrEqual(createdNote.modifiedAt as number);148 });149150 it('does not change createdAt on update', async () => {151 const created = await dispatch({152 type: 'SAVE_NOTE',153 payload: { title: 'Original', body: '' },154 });155 const createdNote = (created as { data: Record<string, unknown> }).data;156 await new Promise(r => setTimeout(r, 10));157 const updated = await dispatch({158 type: 'SAVE_NOTE',159 payload: { id: createdNote.id as string, title: 'Updated', body: '' },160 });161 const updatedNote = (updated as { data: Record<string, unknown> }).data;162 expect(updatedNote.createdAt).toBe(createdNote.createdAt);163 });164165 it('returns error when vault is locked', async () => {166 await lockVault();167 const result = await dispatch({168 type: 'SAVE_NOTE',169 payload: { title: 'Test', body: '' },170 });171 expect(result.ok).toBe(false);172 expect((result as { error: string }).error).toMatch(/vault_locked/);173 });174175 it('returns error when updating nonexistent id', async () => {176 const result = await dispatch({177 type: 'SAVE_NOTE',178 payload: { id: 'nonexistent-id', title: 'Test', body: '' },179 });180 expect(result.ok).toBe(false);181 expect((result as { error: string }).error).toMatch(/not_found/);182 });183 });184185 describe('DELETE_NOTE handler', () => {186 it('soft-deletes a note and removes it from GET_NOTES results', async () => {187 await setupVault();188 const created = await dispatch({189 type: 'SAVE_NOTE',190 payload: { title: 'To delete', body: '' },191 });192 const note = (created as { data: Record<string, unknown> }).data;193 const deleted = await dispatch({194 type: 'DELETE_NOTE',195 payload: { id: note.id as string },196 });197 expect(deleted.ok).toBe(true);198 const notes = await dispatch({ type: 'GET_NOTES' });199 const notesList = (notes as unknown as { data: Array<Record<string, unknown>> }).data;200 expect(notesList.find((n) => n.id === note.id)).toBeUndefined();201 });202203 it('returns error when vault is locked', async () => {204 await setupVault();205 await lockVault();206 const result = await dispatch({207 type: 'DELETE_NOTE',208 payload: { id: 'some-id' },209 });210 expect(result.ok).toBe(false);211 });212 });213214 describe('notes in vault entries', () => {215 it('note entries do not appear in autofill results', async () => {216 await setupVault();217 await dispatch({218 type: 'SAVE_NOTE',219 payload: { title: 'github.com', body: 'Some secret notes' },220 });221 const result = await dispatch({222 type: 'AUTOFILL_QUERY',223 payload: { domain: 'github.com' },224 });225 const data = (result as { data: Array<Record<string, unknown>> }).data;226 const hasNote = data?.some((e) => e.type === 'note');227 expect(hasNote).toBeFalsy();228 });229230 it('note entries appear in vault search', async () => {231 await setupVault();232 await dispatch({233 type: 'SAVE_NOTE',234 payload: { title: 'SSH passphrase', body: 'my server key' },235 });236 const result = await dispatch({237 type: 'SEARCH_VAULT',238 payload: { query: 'SSH' },239 });240 const entries = (result as unknown as { data: Array<Record<string, unknown>> }).data;241 const found = entries?.some((e) => e.type === 'note' && e.title === 'SSH passphrase');242 expect(found).toBe(true);243 });244245 it('note body is searched', async () => {246 await setupVault();247 await dispatch({248 type: 'SAVE_NOTE',249 payload: { title: 'Random title', body: 'backup codes ABCD1234' },250 });251 const result = await dispatch({252 type: 'SEARCH_VAULT',253 payload: { query: 'ABCD1234' },254 });255 const entries = (result as unknown as { data: Array<unknown> }).data;256 expect(entries?.length).toBeGreaterThan(0);257 });258 });259});