sync-pro-gate.test.ts

6.1 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 Pro gating', () => {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);26    // Default: desktop (no Capacitor)27    (globalThis as Record<string, unknown>).Capacitor = undefined;2829    const chromeMock = {30      runtime: {31        id: 'peach-extension',32        onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },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 mockNoPro(): Promise<void> {96    const license = await import('./license');97    vi.spyOn(license, 'isProUser').mockResolvedValue(false);98  }99100  async function mockPro(): Promise<void> {101    const license = await import('./license');102    vi.spyOn(license, 'isProUser').mockResolvedValue(true);103  }104105  function mockDesktop(): void {106    (globalThis as Record<string, unknown>).Capacitor = undefined;107  }108109  function mockNative(): void {110    (globalThis as Record<string, unknown>).Capacitor = { isNativePlatform: () => true };111  }112113  describe('pairing initiation — free', () => {114    it('START_PAIRING_SERVER succeeds without Pro', async () => {115      await mockNoPro();116      mockDesktop();117      const result = await dispatch({ type: 'START_PAIRING_SERVER' });118      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);119    });120121    it('ACCEPT_PAIRING succeeds without Pro', async () => {122      await mockNoPro();123      const result = await dispatch({124        type: 'ACCEPT_PAIRING',125        payload: { token: 'test-token' },126      });127      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);128    });129130    it('SCAN_PAIRING_QR does not return pro_required (returns android_only on desktop)', async () => {131      await mockNoPro();132      mockDesktop();133      const result = await dispatch({134        type: 'SCAN_PAIRING_QR',135        payload: { token: '123456' },136      });137      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);138    });139  });140141  describe('ongoing sync — Pro required', () => {142    it('TRIGGER_SYNC is Pro-gated on desktop', async () => {143      await mockNoPro();144      mockDesktop();145      const result = await dispatch({ type: 'TRIGGER_SYNC' });146      expect(result.ok).toBe(false);147      expect((result as { error: string }).error).toMatch(/pro_required/);148    });149150    it('TRIGGER_SYNC with Pro on desktop does not return pro_required', async () => {151      await mockPro();152      mockDesktop();153      const vault = await import('./vault');154      await vault.createVault('test-pass-12345');155      const result = await dispatch({ type: 'TRIGGER_SYNC' });156      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);157    });158159    it('TRIGGER_SYNC with Pro on native succeeds', async () => {160      await mockPro();161      mockNative();162      const vault = await import('./vault');163      await vault.createVault('test-pass-12345');164      const result = await dispatch({ type: 'TRIGGER_SYNC' });165      expect(result.ok).toBe(true);166      expect((result as { data: { mode: string } }).data?.mode).toBe('server');167    });168  });169});