passkey-toast.test.ts

9.3 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};7980function sendToContentScript(81  listener: MessageListener,82  msg: { type: string; payload?: unknown },83): void {84  const sender = {};85  const sendResponse = vi.fn();86  listener(msg, sender, sendResponse);87}8889// ── PASSKEY_AUTH_RESPONSE background handler ─────────────────────────────────9091describe('PASSKEY_AUTH_RESPONSE handler', () => {92  let storage: StorageShape;93  let listener: (() => MessageListener);9495  beforeEach(async () => {96    vi.resetModules();97    const mocks = installChromeMock();98    storage = mocks.storage;99    listener = mocks.listener;100    storage['peach_settings'] = { breachMonitoring: false };101    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));102    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);103    (globalThis as Record<string, unknown>).chrome = (globalThis as Record<string, unknown>).chrome ?? {};104    ((globalThis as Record<string, unknown>).chrome as Record<string, unknown>).tabs = {105      query: vi.fn((_, cb?: (tabs: unknown[]) => void) => { if (cb) cb([]); return Promise.resolve([]); }),106      sendMessage: vi.fn(async () => ({ ok: true })),107    };108    const msgs = await import('../../background/messages');109    msgs.setupMessageHandler();110    const vault = await import('../../background/vault');111    await vault.ensureSettingsLoaded();112  });113114  async function dispatch(msg: { type: string; payload?: unknown }): Promise<Record<string, unknown>> {115    return new Promise((resolve) => {116      const keepOpen = listener()(msg as unknown as Parameters<MessageListener>[0], { id: 'peach-extension' }, (r) => resolve(r as Record<string, unknown>));117      expect(keepOpen).toBe(true);118    });119  }120121  it('returns ok:true when approved:true and payload is valid', async () => {122    const result = await dispatch({123      type: 'PASSKEY_AUTH_RESPONSE',124      payload: { requestId: 42, approved: true },125    });126    expect(result.ok).toBe(true);127  });128129  it('returns ok:true when approved:false and payload is valid', async () => {130    const result = await dispatch({131      type: 'PASSKEY_AUTH_RESPONSE',132      payload: { requestId: 42, approved: false },133    });134    expect(result.ok).toBe(true);135  });136137  it('returns error when requestId is missing', async () => {138    const result = await dispatch({139      type: 'PASSKEY_AUTH_RESPONSE',140      payload: { approved: true },141    });142    expect(result.ok).toBe(false);143    expect(result.error).toMatch(/requestId/);144  });145146  it('returns error when approved flag is missing', async () => {147    const result = await dispatch({148      type: 'PASSKEY_AUTH_RESPONSE',149      payload: { requestId: 42 },150    });151    expect(result.ok).toBe(false);152    expect(result.error).toMatch(/requestId/);153  });154});155156// ── Toast injection ───────────────────────────────────────────────────────────157158describe('toast injection', () => {159  let contentListener: MessageListener;160161  beforeEach(async () => {162    vi.resetModules();163164    // Reset DOM165    document.body.innerHTML = '';166167    const chromeMock = {168      runtime: {169        sendMessage: vi.fn(async () => ({ ok: true })),170        onMessage: {171          addListener: vi.fn((fn: MessageListener) => {172            contentListener = fn;173          }),174        },175      },176      storage: {177        local: {178          get: vi.fn(async () => ({})),179          set: vi.fn(async () => {}),180        },181      },182    };183    (globalThis as { chrome?: unknown }).chrome = chromeMock;184185    // Import the content script to register its listener186    await import('./inject');187  });188189  it('shows toast when SHOW_PASSKEY_TOAST message received', () => {190    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });191    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();192  });193194  it('second SHOW_PASSKEY_TOAST replaces existing toast', () => {195    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });196    sendToContentScript(contentListener, {197      type: 'SHOW_PASSKEY_TOAST',198      payload: { ...mockPayload, requestId: 'req-2' },199    });200    expect(document.querySelectorAll('#peach-passkey-toast-root').length).toBe(1);201  });202203  it('uses shadow DOM — container has a shadowRoot', () => {204    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });205    const root = document.querySelector('#peach-passkey-toast-root');206    // shadow mode is 'closed', so shadowRoot returns null from the outside;207    // we verify the element exists and is positioned correctly instead.208    expect(root).toBeTruthy();209    expect(root?.tagName.toLowerCase()).toBe('div');210  });211212  it('DISMISS_PASSKEY_TOAST removes toast', () => {213    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });214    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();215    sendToContentScript(contentListener, { type: 'DISMISS_PASSKEY_TOAST' });216    // Container element stays in DOM; the toast content inside it is cleared217    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();218  });219});220221// ── Autofill blocking while toast active ──────────────────────────────────────222223describe('autofill blocking while toast active', () => {224  let contentListener: MessageListener;225226  beforeEach(async () => {227    vi.resetModules();228    document.body.innerHTML = '';229230    const chromeMock = {231      runtime: {232        sendMessage: vi.fn(async () => ({ ok: true })),233        onMessage: {234          addListener: vi.fn((fn: MessageListener) => {235            contentListener = fn;236          }),237        },238      },239      storage: {240        local: {241          get: vi.fn(async () => ({})),242          set: vi.fn(async () => {}),243        },244      },245    };246    (globalThis as { chrome?: unknown }).chrome = chromeMock;247    await import('./inject');248  });249250  it('toast is shown after SHOW_PASSKEY_TOAST', () => {251    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });252    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();253  });254255  it('toast is cleared after DISMISS_PASSKEY_TOAST', () => {256    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });257    sendToContentScript(contentListener, { type: 'DISMISS_PASSKEY_TOAST' });258    // The container persists; the toast content is cleaned up259    const root = document.querySelector('#peach-passkey-toast-root');260    expect(root).toBeTruthy();261  });262});