passkey-toast.test.ts

9.4 KB · raw

1// @vitest-environment jsdom23import { describe, it, expect, vi, beforeEach } from 'vitest';45// ── chrome mock ──────────────────────────────────────────────────────────────67type MessageListener = (8  msg: unknown,9  sender: unknown,10  sendResponse: (r: unknown) => void11) => boolean;1213type StorageShape = Record<string, unknown>;1415function installChromeMock(): {16  storage: StorageShape;17  sessionStorage: StorageShape;18  listener: () => MessageListener;19} {20  const storage: StorageShape = {};21  const sessionStorage: StorageShape = {};22  let messageListener: MessageListener | null = null;2324  const chromeMock = {25    runtime: {26      id: 'peach-extension',27      getURL: (path: string) => `chrome-extension://peach-extension/${path}`,28      sendMessage: vi.fn(async () => ({ ok: true })),29      onMessage: {30        addListener: vi.fn((fn: MessageListener) => {31          messageListener = fn;32        }),33      },34    },35    storage: {36      local: {37        get: vi.fn(async (key?: string | string[]) => {38          if (!key) return storage;39          if (typeof key === 'string') return { [key]: storage[key] };40          return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});41        }),42        set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),43        remove: vi.fn(async (k: string | string[]) => {44          const keys = Array.isArray(k) ? k : [k];45          for (const key of keys) delete storage[key];46        }),47      },48      session: {49        get: vi.fn(async (key?: string | string[]) => {50          if (!key) return sessionStorage;51          if (typeof key === 'string') return { [key]: sessionStorage[key] };52          return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});53        }),54        set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),55        remove: vi.fn(async (k: string | string[]) => {56          const keys = Array.isArray(k) ? k : [k];57          for (const key of keys) delete sessionStorage[key];58        }),59      },60    },61  };62  (globalThis as { chrome?: typeof chrome }).chrome = chromeMock as unknown as typeof chrome;6364  return {65    storage,66    sessionStorage,67    listener: () => messageListener!,68  };69}7071// ── helpers ──────────────────────────────────────────────────────────────────7273const mockPayload = {74  site: 'example.com',75  displayName: 'Example',76  username: 'june',77  requestId: 'req-1',78  toastToken: 'extension-token',79};8081function sendToContentScript(82  listener: MessageListener,83  msg: { type: string; payload?: unknown },84): void {85  const sender = {};86  const sendResponse = vi.fn();87  listener(msg, sender, sendResponse);88}8990// ── PASSKEY_AUTH_RESPONSE background handler ─────────────────────────────────9192describe('PASSKEY_AUTH_RESPONSE handler', () => {93  let storage: StorageShape;94  let listener: (() => MessageListener);9596  beforeEach(async () => {97    vi.resetModules();98    const mocks = installChromeMock();99    storage = mocks.storage;100    listener = mocks.listener;101    storage['peach_settings'] = { breachMonitoring: false };102    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));103    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);104    (globalThis as Record<string, unknown>).chrome = (globalThis as Record<string, unknown>).chrome ?? {};105    ((globalThis as Record<string, unknown>).chrome as Record<string, unknown>).tabs = {106      query: vi.fn((_, cb?: (tabs: unknown[]) => void) => { if (cb) cb([]); return Promise.resolve([]); }),107      sendMessage: vi.fn(async () => ({ ok: true })),108    };109    const msgs = await import('../../background/messages');110    msgs.setupMessageHandler();111    const vault = await import('../../background/vault');112    await vault.ensureSettingsLoaded();113  });114115  async function dispatch(msg: { type: string; payload?: unknown }): Promise<Record<string, unknown>> {116    return new Promise((resolve) => {117      const keepOpen = listener()(msg as unknown as Parameters<MessageListener>[0], { id: 'peach-extension' }, (r) => resolve(r as Record<string, unknown>));118      expect(keepOpen).toBe(true);119    });120  }121122  it('returns ok:true when approved:true and payload is valid', async () => {123    const result = await dispatch({124      type: 'PASSKEY_AUTH_RESPONSE',125      payload: { requestId: 42, approved: true },126    });127    expect(result.ok).toBe(true);128  });129130  it('returns ok:true when approved:false and payload is valid', async () => {131    const result = await dispatch({132      type: 'PASSKEY_AUTH_RESPONSE',133      payload: { requestId: 42, approved: false },134    });135    expect(result.ok).toBe(true);136  });137138  it('returns error when requestId is missing', async () => {139    const result = await dispatch({140      type: 'PASSKEY_AUTH_RESPONSE',141      payload: { approved: true },142    });143    expect(result.ok).toBe(false);144    expect(result.error).toMatch(/requestId/);145  });146147  it('returns error when approved flag is missing', async () => {148    const result = await dispatch({149      type: 'PASSKEY_AUTH_RESPONSE',150      payload: { requestId: 42 },151    });152    expect(result.ok).toBe(false);153    expect(result.error).toMatch(/requestId/);154  });155});156157// ── Toast injection ───────────────────────────────────────────────────────────158159describe('toast injection', () => {160  let contentListener: MessageListener;161162  beforeEach(async () => {163    vi.resetModules();164165    // Reset DOM166    document.body.innerHTML = '';167168    const chromeMock = {169      runtime: {170        sendMessage: vi.fn(async () => ({ ok: true })),171        onMessage: {172          addListener: vi.fn((fn: MessageListener) => {173            contentListener = fn;174          }),175        },176      },177      storage: {178        local: {179          get: vi.fn(async () => ({})),180          set: vi.fn(async () => {}),181        },182      },183    };184    (globalThis as { chrome?: unknown }).chrome = chromeMock;185186    // Import the content script to register its listener187    await import('./inject');188  });189190  it('shows toast when SHOW_PASSKEY_TOAST message received', () => {191    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });192    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();193  });194195  it('second SHOW_PASSKEY_TOAST replaces existing toast', () => {196    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });197    sendToContentScript(contentListener, {198      type: 'SHOW_PASSKEY_TOAST',199      payload: { ...mockPayload, requestId: 'req-2' },200    });201    expect(document.querySelectorAll('#peach-passkey-toast-root').length).toBe(1);202  });203204  it('uses shadow DOM — container has a shadowRoot', () => {205    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });206    const root = document.querySelector('#peach-passkey-toast-root');207    // shadow mode is 'closed', so shadowRoot returns null from the outside;208    // we verify the element exists and is positioned correctly instead.209    expect(root).toBeTruthy();210    expect(root?.tagName.toLowerCase()).toBe('div');211  });212213  it('DISMISS_PASSKEY_TOAST removes toast', () => {214    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });215    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();216    sendToContentScript(contentListener, { type: 'DISMISS_PASSKEY_TOAST' });217    // Container element stays in DOM; the toast content inside it is cleared218    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();219  });220});221222// ── Autofill blocking while toast active ──────────────────────────────────────223224describe('autofill blocking while toast active', () => {225  let contentListener: MessageListener;226227  beforeEach(async () => {228    vi.resetModules();229    document.body.innerHTML = '';230231    const chromeMock = {232      runtime: {233        sendMessage: vi.fn(async () => ({ ok: true })),234        onMessage: {235          addListener: vi.fn((fn: MessageListener) => {236            contentListener = fn;237          }),238        },239      },240      storage: {241        local: {242          get: vi.fn(async () => ({})),243          set: vi.fn(async () => {}),244        },245      },246    };247    (globalThis as { chrome?: unknown }).chrome = chromeMock;248    await import('./inject');249  });250251  it('toast is shown after SHOW_PASSKEY_TOAST', () => {252    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });253    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();254  });255256  it('toast is cleared after DISMISS_PASSKEY_TOAST', () => {257    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });258    sendToContentScript(contentListener, { type: 'DISMISS_PASSKEY_TOAST' });259    // The container persists; the toast content is cleaned up260    const root = document.querySelector('#peach-passkey-toast-root');261    expect(root).toBeTruthy();262  });263});