notes.test.ts

9.3 KB · raw

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