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