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