notes.test.ts

9.4 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        getURL: (path = '') => `chrome-extension://peach-extension/${path}`,31        onMessage: {32          addListener: vi.fn((fn: MessageListener) => { listener = fn; }),33        },34        sendMessage: vi.fn(async () => ({ ok: true })),35      },36      tabs: {37        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {38          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }39          return Promise.resolve([{ id: 1 }]);40        }),41        sendMessage: vi.fn(async () => ({ ok: true })),42      },43      storage: {44        local: {45          get: vi.fn(async (key?: string | string[]) => {46            if (!key) return storage;47            if (typeof key === 'string') return { [key]: storage[key] };48            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});49          }),50          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),51          remove: vi.fn(async (keys: string | string[]) => {52            const list = Array.isArray(keys) ? keys : [keys];53            for (const key of list) delete storage[key];54          }),55        },56        session: {57          get: vi.fn(async (key?: string | string[]) => {58            if (!key) return sessionStorage;59            if (typeof key === 'string') return { [key]: sessionStorage[key] };60            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});61          }),62          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),63          remove: vi.fn(async (keys: string | string[]) => {64            const list = Array.isArray(keys) ? keys : [keys];65            for (const key of list) delete sessionStorage[key];66          }),67          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),68        },69      },70      alarms: {71        clear: vi.fn(), create: vi.fn(),72        onAlarm: { addListener: vi.fn() },73      },74      idle: {75        setDetectionInterval: vi.fn(),76        onStateChanged: { addListener: vi.fn() },77      },78      scripting: { executeScript: vi.fn(async () => []) },79    } as unknown as typeof chrome;8081    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8283    const messages = await import('./messages');84    messages.setupMessageHandler();85    const vault = await import('./vault');86    await vault.ensureSettingsLoaded();87  });8889  async function dispatch(message: Message): Promise<MessageResponse> {90    return new Promise<MessageResponse>((resolve) => {91      const keepOpen = listener(message, {92        id: 'peach-extension',93        url: 'chrome-extension://peach-extension/popup.html',94      }, resolve);95      expect(keepOpen).toBe(true);96    });97  }9899  async function setupVault(): Promise<void> {100    const vault = await import('./vault');101    await vault.createVault('test-pass-12345');102  }103104  async function lockVault(): Promise<void> {105    const vault = await import('./vault');106    vault.lock();107  }108109  describe('SAVE_NOTE handler', () => {110    beforeEach(async () => { await setupVault(); });111112    it('creates a new note entry with type="note"', async () => {113      const result = await dispatch({114        type: 'SAVE_NOTE',115        payload: { title: 'Test note', body: 'Hello world' },116      });117      expect(result.ok).toBe(true);118      const note = (result as { data: Record<string, unknown> }).data;119      expect(note.type).toBe('note');120      expect(note.title).toBe('Test note');121      expect(note.id).toBeTruthy();122    });123124    it('assigns createdAt and modifiedAt timestamps', async () => {125      const before = Date.now();126      const result = await dispatch({127        type: 'SAVE_NOTE',128        payload: { title: 'Timestamps', body: '' },129      });130      const after = Date.now();131      const note = (result as { data: Record<string, unknown> }).data;132      expect(note.createdAt as number).toBeGreaterThanOrEqual(before);133      expect(note.createdAt as number).toBeLessThanOrEqual(after);134      expect(note.modifiedAt as number).toBeGreaterThanOrEqual(before);135    });136137    it('updates existing note by id', async () => {138      const created = await dispatch({139        type: 'SAVE_NOTE',140        payload: { title: 'Original', body: 'First' },141      });142      const createdNote = (created as { data: Record<string, unknown> }).data;143      const updated = await dispatch({144        type: 'SAVE_NOTE',145        payload: { id: createdNote.id as string, title: 'Updated', body: 'Second' },146      });147      expect(updated.ok).toBe(true);148      const updatedNote = (updated as { data: Record<string, unknown> }).data;149      expect(updatedNote.id).toBe(createdNote.id);150      expect(updatedNote.title).toBe('Updated');151      expect(updatedNote.modifiedAt as number).toBeGreaterThanOrEqual(createdNote.modifiedAt as number);152    });153154    it('does not change createdAt on update', async () => {155      const created = await dispatch({156        type: 'SAVE_NOTE',157        payload: { title: 'Original', body: '' },158      });159      const createdNote = (created as { data: Record<string, unknown> }).data;160      await new Promise(r => setTimeout(r, 10));161      const updated = await dispatch({162        type: 'SAVE_NOTE',163        payload: { id: createdNote.id as string, title: 'Updated', body: '' },164      });165      const updatedNote = (updated as { data: Record<string, unknown> }).data;166      expect(updatedNote.createdAt).toBe(createdNote.createdAt);167    });168169    it('returns error when vault is locked', async () => {170      await lockVault();171      const result = await dispatch({172        type: 'SAVE_NOTE',173        payload: { title: 'Test', body: '' },174      });175      expect(result.ok).toBe(false);176      expect((result as { error: string }).error).toMatch(/vault_locked/);177    });178179    it('returns error when updating nonexistent id', async () => {180      const result = await dispatch({181        type: 'SAVE_NOTE',182        payload: { id: 'nonexistent-id', title: 'Test', body: '' },183      });184      expect(result.ok).toBe(false);185      expect((result as { error: string }).error).toMatch(/not_found/);186    });187  });188189  describe('DELETE_NOTE handler', () => {190    it('soft-deletes a note and removes it from GET_NOTES results', async () => {191      await setupVault();192      const created = await dispatch({193        type: 'SAVE_NOTE',194        payload: { title: 'To delete', body: '' },195      });196      const note = (created as { data: Record<string, unknown> }).data;197      const deleted = await dispatch({198        type: 'DELETE_NOTE',199        payload: { id: note.id as string },200      });201      expect(deleted.ok).toBe(true);202      const notes = await dispatch({ type: 'GET_NOTES' });203      const notesList = (notes as unknown as { data: Array<Record<string, unknown>> }).data;204      expect(notesList.find((n) => n.id === note.id)).toBeUndefined();205    });206207    it('returns error when vault is locked', async () => {208      await setupVault();209      await lockVault();210      const result = await dispatch({211        type: 'DELETE_NOTE',212        payload: { id: 'some-id' },213      });214      expect(result.ok).toBe(false);215    });216  });217218  describe('notes in vault entries', () => {219    it('note entries do not appear in autofill results', async () => {220      await setupVault();221      await dispatch({222        type: 'SAVE_NOTE',223        payload: { title: 'github.com', body: 'Some secret notes' },224      });225      const result = await dispatch({226        type: 'AUTOFILL_QUERY',227        payload: { domain: 'github.com' },228      });229      const data = (result as { data: Array<Record<string, unknown>> }).data;230      const hasNote = data?.some((e) => e.type === 'note');231      expect(hasNote).toBeFalsy();232    });233234    it('note entries appear in vault search', async () => {235      await setupVault();236      await dispatch({237        type: 'SAVE_NOTE',238        payload: { title: 'SSH passphrase', body: 'my server key' },239      });240      const result = await dispatch({241        type: 'SEARCH_VAULT',242        payload: { query: 'SSH' },243      });244      const entries = (result as unknown as { data: Array<Record<string, unknown>> }).data;245      const found = entries?.some((e) => e.type === 'note' && e.title === 'SSH passphrase');246      expect(found).toBe(true);247    });248249    it('note body is searched', async () => {250      await setupVault();251      await dispatch({252        type: 'SAVE_NOTE',253        payload: { title: 'Random title', body: 'backup codes ABCD1234' },254      });255      const result = await dispatch({256        type: 'SEARCH_VAULT',257        payload: { query: 'ABCD1234' },258      });259      const entries = (result as unknown as { data: Array<unknown> }).data;260      expect(entries?.length).toBeGreaterThan(0);261    });262  });263});