sync-screen.test.ts

6.6 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('sync screen 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: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },31        sendMessage: vi.fn(async () => ({ ok: true })),32      },33      tabs: {34        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {35          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }36          return Promise.resolve([{ id: 1 }]);37        }),38        sendMessage: vi.fn(async () => ({ ok: true })),39      },40      storage: {41        local: {42          get: vi.fn(async (key?: string | string[]) => {43            if (!key) return storage;44            if (typeof key === 'string') return { [key]: storage[key] };45            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});46          }),47          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),48          remove: vi.fn(async (keys: string | string[]) => {49            const list = Array.isArray(keys) ? keys : [keys];50            for (const key of list) delete storage[key];51          }),52        },53        session: {54          get: vi.fn(async (key?: string | string[]) => {55            if (!key) return sessionStorage;56            if (typeof key === 'string') return { [key]: sessionStorage[key] };57            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});58          }),59          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),60          remove: vi.fn(async (keys: string | string[]) => {61            const list = Array.isArray(keys) ? keys : [keys];62            for (const key of list) delete sessionStorage[key];63          }),64          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),65        },66      },67      alarms: {68        clear: vi.fn(), create: vi.fn(),69        onAlarm: { addListener: vi.fn() },70      },71      idle: {72        setDetectionInterval: vi.fn(),73        onStateChanged: { addListener: vi.fn() },74      },75      scripting: { executeScript: vi.fn(async () => []) },76    } as unknown as typeof chrome;7778    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;7980    const messages = await import('./messages');81    messages.setupMessageHandler();82    const vault = await import('./vault');83    await vault.ensureSettingsLoaded();84  });8586  async function dispatch(message: Message): Promise<MessageResponse> {87    return new Promise<MessageResponse>((resolve) => {88      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);89      expect(keepOpen).toBe(true);90    });91  }9293  function storageSet(key: string, value: unknown): void {94    storage[key] = value;95  }9697  describe('GET_PAIRED_DEVICES handler', () => {98    it('returns empty array when no devices paired', async () => {99      const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });100      expect(result.ok).toBe(true);101      const data = (result as { data: { devices: unknown[] } }).data;102      expect(data.devices).toEqual([]);103    });104105    it('returns paired devices list', async () => {106      storage['peach_paired_devices'] = [107        {108          deviceId: 'abc',109          displayName: 'MacBook',110          x25519PublicKey: 'abc',111          pairedAt: Date.now(),112          lastKnownIp: '192.168.1.1',113        },114      ];115      const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });116      const data = (result as { data: { devices: Array<Record<string, unknown>> } }).data;117      expect(data.devices.length).toBe(1);118      expect(data.devices[0].displayName).toBe('MacBook');119    });120121    it('does not expose private keys in paired devices', async () => {122      storage['peach_paired_devices'] = [123        {124          deviceId: 'abc',125          displayName: 'MacBook',126          x25519PublicKey: 'abc',127          pairedAt: Date.now(),128          lastKnownIp: '192.168.1.1',129        },130      ];131      const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });132      const data = (result as { data: { devices: Array<Record<string, unknown>> } }).data;133      expect(data.devices[0].privateKey).toBeUndefined();134    });135  });136137  describe('GET_SYNC_STATUS handler', () => {138    it('returns never status when never synced', async () => {139      const result = await dispatch({ type: 'GET_SYNC_STATUS' });140      expect(result.ok).toBe(true);141      const data = (result as { data: { status: string; lastSyncAt: unknown } }).data;142      expect(data.status).toBe('never');143      expect(data.lastSyncAt).toBeNull();144    });145146    it('returns up_to_date after successful sync', async () => {147      storageSet('peach_last_sync_at', String(Date.now()));148      const result = await dispatch({ type: 'GET_SYNC_STATUS' });149      const data = (result as { data: { status: string; lastSyncAt: unknown } }).data;150      expect(data.status).toBe('up_to_date');151      expect(data.lastSyncAt).toBeTruthy();152    });153  });154155  describe('RESET_SYNC_IDENTITY handler', () => {156    it('is Pro-gated', async () => {157      const result = await dispatch({ type: 'RESET_SYNC_IDENTITY' });158      expect(result.ok).toBe(false);159      expect((result as { error: string }).error).toMatch(/pro_required/);160    });161162    it('clears paired devices on reset for Pro users', async () => {163      storage['peach_paired_devices'] = [164        { deviceId: 'a', displayName: 'iPhone', x25519PublicKey: 'pk-a', pairedAt: 1 },165        { deviceId: 'b', displayName: 'MacBook', x25519PublicKey: 'pk-b', pairedAt: 2 },166      ];167      const license = await import('./license');168      vi.spyOn(license, 'isProUser').mockResolvedValue(true);169      const result = await dispatch({ type: 'RESET_SYNC_IDENTITY' });170      expect(result.ok).toBe(true);171      expect((result as { data: { removedDevices: number } }).data?.removedDevices).toBe(2);172      expect(storage['peach_paired_devices']).toEqual([]);173    });174  });175});