import { describe, it, expect, beforeEach } from 'vitest'; import { vi } from 'vitest'; import type { Message, MessageResponse } from '../shared/types'; type StorageShape = Record; type MessageListener = ( msg: unknown, sender: chrome.runtime.MessageSender, sendResponse: (response: MessageResponse) => void ) => boolean; describe('sync Pro gating', () => { let storage: StorageShape; let sessionStorage: StorageShape; let listener: MessageListener; beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); storage = { peach_settings: { breachMonitoring: false } }; sessionStorage = {}; vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' }))); vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false); // Default: desktop (no Capacitor) (globalThis as Record).Capacitor = undefined; const chromeMock = { runtime: { id: 'peach-extension', onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) }, sendMessage: vi.fn(async () => ({ ok: true })), }, tabs: { query: vi.fn((_, cb?: (tabs: unknown[]) => void) => { if (typeof cb === 'function') { cb([{ id: 1 }]); return; } return Promise.resolve([{ id: 1 }]); }), sendMessage: vi.fn(async () => ({ ok: true })), }, storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === 'string') return { [key]: storage[key] }; return key.reduce>((acc, k) => { acc[k] = storage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete storage[key]; }), }, session: { get: vi.fn(async (key?: string | string[]) => { if (!key) return sessionStorage; if (typeof key === 'string') return { [key]: sessionStorage[key] }; return key.reduce>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(sessionStorage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete sessionStorage[key]; }), clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, scripting: { executeScript: vi.fn(async () => []) }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; const messages = await import('./messages'); messages.setupMessageHandler(); const vault = await import('./vault'); await vault.ensureSettingsLoaded(); }); async function dispatch(message: Message): Promise { return new Promise((resolve) => { const keepOpen = listener(message, { id: 'peach-extension' }, resolve); expect(keepOpen).toBe(true); }); } async function mockNoPro(): Promise { const license = await import('./license'); vi.spyOn(license, 'isProUser').mockResolvedValue(false); } async function mockPro(): Promise { const license = await import('./license'); vi.spyOn(license, 'isProUser').mockResolvedValue(true); } function mockDesktop(): void { (globalThis as Record).Capacitor = undefined; } function mockNative(): void { (globalThis as Record).Capacitor = { isNativePlatform: () => true }; } describe('pairing initiation — free', () => { it('START_PAIRING_SERVER succeeds without Pro', async () => { await mockNoPro(); mockDesktop(); const result = await dispatch({ type: 'START_PAIRING_SERVER' }); expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/); }); it('ACCEPT_PAIRING succeeds without Pro', async () => { await mockNoPro(); const result = await dispatch({ type: 'ACCEPT_PAIRING', payload: { token: 'test-token' }, }); expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/); }); it('SCAN_PAIRING_QR does not return pro_required (returns android_only on desktop)', async () => { await mockNoPro(); mockDesktop(); const result = await dispatch({ type: 'SCAN_PAIRING_QR', payload: { token: '123456' }, }); expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/); }); }); describe('ongoing sync — Pro required', () => { it('TRIGGER_SYNC is Pro-gated on desktop', async () => { await mockNoPro(); mockDesktop(); const result = await dispatch({ type: 'TRIGGER_SYNC' }); expect(result.ok).toBe(false); expect((result as { error: string }).error).toMatch(/pro_required/); }); it('TRIGGER_SYNC with Pro on desktop does not return pro_required', async () => { await mockPro(); mockDesktop(); const vault = await import('./vault'); await vault.createVault('test-pass-12345'); const result = await dispatch({ type: 'TRIGGER_SYNC' }); expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/); }); it('TRIGGER_SYNC with Pro on native succeeds', async () => { await mockPro(); mockNative(); const vault = await import('./vault'); await vault.createVault('test-pass-12345'); const result = await dispatch({ type: 'TRIGGER_SYNC' }); expect(result.ok).toBe(true); expect((result as { data: { mode: string } }).data?.mode).toBe('server'); }); }); });