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('hasPaired flag', () => {14 let storage: StorageShape;1516 function installChromeMock(s: StorageShape): void {17 const chromeMock = {18 storage: {19 local: {20 get: vi.fn(async (key?: string | string[]) => {21 if (!key) return s;22 if (typeof key === 'string') return { [key]: s[key] };23 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = s[k]; return acc; }, {});24 }),25 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(s, next); }),26 remove: vi.fn(async (keys: string | string[]) => {27 const list = Array.isArray(keys) ? keys : [keys];28 for (const key of list) delete s[key];29 }),30 },31 },32 } as unknown as typeof chrome;33 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;34 }3536 beforeEach(async () => {37 vi.resetModules();38 storage = {};39 installChromeMock(storage);40 });4142 async function clearStorage(): Promise<void> {43 for (const key of Object.keys(storage)) delete storage[key];44 }4546 it('getHasPaired returns false when never paired', async () => {47 await clearStorage();48 const { getHasPaired } = await import('./sync-storage');49 const result = await getHasPaired();50 expect(result).toBe(false);51 });5253 it('setHasPaired persists across calls', async () => {54 const { setHasPaired, getHasPaired } = await import('./sync-storage');55 await setHasPaired();56 const result = await getHasPaired();57 expect(result).toBe(true);58 });5960 it('hasPaired remains true after multiple calls to setHasPaired', async () => {61 const { setHasPaired, getHasPaired } = await import('./sync-storage');62 await setHasPaired();63 await setHasPaired(); 64 const result = await getHasPaired();65 expect(result).toBe(true);66 });67});6869describe('PAIRING_COMPLETED handler', () => {70 let storage: StorageShape;71 let sessionStorage: StorageShape;72 let listener: MessageListener;7374 beforeEach(async () => {75 vi.resetModules();76 vi.useRealTimers();77 storage = { peach_settings: { breachMonitoring: false } };78 sessionStorage = {};7980 vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));81 vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);8283 const chromeMock = {84 runtime: {85 id: 'peach-extension',86 onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },87 sendMessage: vi.fn(async () => ({ ok: true })),88 },89 tabs: {90 query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {91 if (typeof cb === 'function') { cb([{ id: 1 }]); return; }92 return Promise.resolve([{ id: 1 }]);93 }),94 sendMessage: vi.fn(async () => ({ ok: true })),95 },96 storage: {97 local: {98 get: vi.fn(async (key?: string | string[]) => {99 if (!key) return storage;100 if (typeof key === 'string') return { [key]: storage[key] };101 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});102 }),103 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),104 remove: vi.fn(async (keys: string | string[]) => {105 const list = Array.isArray(keys) ? keys : [keys];106 for (const key of list) delete storage[key];107 }),108 },109 session: {110 get: vi.fn(async (key?: string | string[]) => {111 if (!key) return sessionStorage;112 if (typeof key === 'string') return { [key]: sessionStorage[key] };113 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});114 }),115 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),116 remove: vi.fn(async (keys: string | string[]) => {117 const list = Array.isArray(keys) ? keys : [keys];118 for (const key of list) delete sessionStorage[key];119 }),120 clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),121 },122 },123 alarms: {124 clear: vi.fn(), create: vi.fn(),125 onAlarm: { addListener: vi.fn() },126 },127 idle: {128 setDetectionInterval: vi.fn(),129 onStateChanged: { addListener: vi.fn() },130 },131 scripting: { executeScript: vi.fn(async () => []) },132 } as unknown as typeof chrome;133134 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;135136 const messages = await import('./messages');137 messages.setupMessageHandler();138 const vault = await import('./vault');139 await vault.ensureSettingsLoaded();140 });141142 async function dispatch(message: Message): Promise<MessageResponse> {143 return new Promise<MessageResponse>((resolve) => {144 const keepOpen = listener(message, { id: 'peach-extension' }, resolve);145 expect(keepOpen).toBe(true);146 });147 }148149 it('flag is set when PAIRING_COMPLETED fires', async () => {150 151 152 153 const messages = await import('./messages');154 messages.markPairingHandshakeVerified('abc123');155 await dispatch({156 type: 'PAIRING_COMPLETED',157 payload: {158 deviceName: 'iPhone',159 publicKeyHex: 'abc123',160 deviceIp: '192.168.1.50',161 },162 });163 const { getHasPaired } = await import('./sync-storage');164 const paired = await getHasPaired();165 expect(paired).toBe(true);166 });167168 it('PAIRING_COMPLETED stores device with lastKnownIp and deviceName', async () => {169 const messages = await import('./messages');170 messages.markPairingHandshakeVerified('abc123');171 await dispatch({172 type: 'PAIRING_COMPLETED',173 payload: {174 deviceName: 'iPhone',175 publicKeyHex: 'abc123',176 deviceIp: '192.168.1.50',177 },178 });179 const { getPairedDevices } = await import('./sync-storage');180 const devices = await getPairedDevices();181 expect(devices[0].lastKnownIp).toBe('192.168.1.50');182 expect(devices[0].displayName).toBe('iPhone');183 });184});