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      sendMessage: vi.fn(async () => ({ ok: true })),28      onMessage: {29        addListener: vi.fn((fn: MessageListener) => {30          messageListener = fn;31        }),32      },33    },34    storage: {35      local: {36        get: vi.fn(async (key?: string | string[]) => {37          if (!key) return storage;38          if (typeof key === 'string') return { [key]: storage[key] };39          return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});40        }),41        set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),42        remove: vi.fn(async (k: string | string[]) => {43          const keys = Array.isArray(k) ? k : [k];44          for (const key of keys) delete storage[key];45        }),46      },47      session: {48        get: vi.fn(async (key?: string | string[]) => {49          if (!key) return sessionStorage;50          if (typeof key === 'string') return { [key]: sessionStorage[key] };51          return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});52        }),53        set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),54        remove: vi.fn(async (k: string | string[]) => {55          const keys = Array.isArray(k) ? k : [k];56          for (const key of keys) delete sessionStorage[key];57        }),58      },59    },60  };61  (globalThis as { chrome?: typeof chrome }).chrome = chromeMock as unknown as typeof chrome;6263  return {64    storage,65    sessionStorage,66    listener: () => messageListener!,67  };68}6970// ── helpers ──────────────────────────────────────────────────────────────────7172const mockPayload = {73  site: 'example.com',74  displayName: 'Example',75  username: 'june',76  requestId: 'req-1',77};7879function sendToContentScript(80  listener: MessageListener,81  msg: { type: string; payload?: unknown },82): void {83  const sender = {};84  const sendResponse = vi.fn();85  listener(msg, sender, sendResponse);86}8788// ── PASSKEY_AUTH_RESPONSE background handler ─────────────────────────────────8990describe('PASSKEY_AUTH_RESPONSE handler', () => {91  let storage: StorageShape;92  let listener: (() => MessageListener);9394  beforeEach(async () => {95    vi.resetModules();96    const mocks = installChromeMock();97    storage = mocks.storage;98    listener = mocks.listener;99    storage['peach_settings'] = { breachMonitoring: false };100    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));101    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);102    (globalThis as Record<string, unknown>).chrome = (globalThis as Record<string, unknown>).chrome ?? {};103    ((globalThis as Record<string, unknown>).chrome as Record<string, unknown>).tabs = {104      query: vi.fn((_, cb?: (tabs: unknown[]) => void) => { if (cb) cb([]); return Promise.resolve([]); }),105      sendMessage: vi.fn(async () => ({ ok: true })),106    };107    const msgs = await import('../../background/messages');108    msgs.setupMessageHandler();109    const vault = await import('../../background/vault');110    await vault.ensureSettingsLoaded();111  });112113  async function dispatch(msg: { type: string; payload?: unknown }): Promise<Record<string, unknown>> {114    return new Promise((resolve) => {115      const keepOpen = listener()(msg as unknown as Parameters<MessageListener>[0], { id: 'peach-extension' }, (r) => resolve(r as Record<string, unknown>));116      expect(keepOpen).toBe(true);117    });118  }119120  it('returns ok:true when approved:true and payload is valid', async () => {121    const result = await dispatch({122      type: 'PASSKEY_AUTH_RESPONSE',123      payload: { requestId: 42, approved: true },124    });125    expect(result.ok).toBe(true);126  });127128  it('returns ok:true when approved:false and payload is valid', async () => {129    const result = await dispatch({130      type: 'PASSKEY_AUTH_RESPONSE',131      payload: { requestId: 42, approved: false },132    });133    expect(result.ok).toBe(true);134  });135136  it('returns error when requestId is missing', async () => {137    const result = await dispatch({138      type: 'PASSKEY_AUTH_RESPONSE',139      payload: { approved: true },140    });141    expect(result.ok).toBe(false);142    expect(result.error).toMatch(/requestId/);143  });144145  it('returns error when approved flag is missing', async () => {146    const result = await dispatch({147      type: 'PASSKEY_AUTH_RESPONSE',148      payload: { requestId: 42 },149    });150    expect(result.ok).toBe(false);151    expect(result.error).toMatch(/requestId/);152  });153});154155// ── Toast injection ───────────────────────────────────────────────────────────156157describe('toast injection', () => {158  let contentListener: MessageListener;159160  beforeEach(async () => {161    vi.resetModules();162163    // Reset DOM164    document.body.innerHTML = '';165166    const chromeMock = {167      runtime: {168        sendMessage: vi.fn(async () => ({ ok: true })),169        onMessage: {170          addListener: vi.fn((fn: MessageListener) => {171            contentListener = fn;172          }),173        },174      },175      storage: {176        local: {177          get: vi.fn(async () => ({})),178          set: vi.fn(async () => {}),179        },180      },181    };182    (globalThis as { chrome?: unknown }).chrome = chromeMock;183184    // Import the content script to register its listener185    await import('./inject');186  });187188  it('shows toast when SHOW_PASSKEY_TOAST message received', () => {189    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });190    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();191  });192193  it('second SHOW_PASSKEY_TOAST replaces existing toast', () => {194    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });195    sendToContentScript(contentListener, {196      type: 'SHOW_PASSKEY_TOAST',197      payload: { ...mockPayload, requestId: 'req-2' },198    });199    expect(document.querySelectorAll('#peach-passkey-toast-root').length).toBe(1);200  });201202  it('uses shadow DOM — container has a shadowRoot', () => {203    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });204    const root = document.querySelector('#peach-passkey-toast-root');205    // shadow mode is 'closed', so shadowRoot returns null from the outside;206    // we verify the element exists and is positioned correctly instead.207    expect(root).toBeTruthy();208    expect(root?.tagName.toLowerCase()).toBe('div');209  });210211  it('DISMISS_PASSKEY_TOAST removes toast', () => {212    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });213    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();214    sendToContentScript(contentListener, { type: 'DISMISS_PASSKEY_TOAST' });215    // Container element stays in DOM; the toast content inside it is cleared216    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();217  });218});219220// ── Autofill blocking while toast active ──────────────────────────────────────221222describe('autofill blocking while toast active', () => {223  let contentListener: MessageListener;224225  beforeEach(async () => {226    vi.resetModules();227    document.body.innerHTML = '';228229    const chromeMock = {230      runtime: {231        sendMessage: vi.fn(async () => ({ ok: true })),232        onMessage: {233          addListener: vi.fn((fn: MessageListener) => {234            contentListener = fn;235          }),236        },237      },238      storage: {239        local: {240          get: vi.fn(async () => ({})),241          set: vi.fn(async () => {}),242        },243      },244    };245    (globalThis as { chrome?: unknown }).chrome = chromeMock;246    await import('./inject');247  });248249  it('toast is shown after SHOW_PASSKEY_TOAST', () => {250    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });251    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();252  });253254  it('toast is cleared after DISMISS_PASSKEY_TOAST', () => {255    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });256    sendToContentScript(contentListener, { type: 'DISMISS_PASSKEY_TOAST' });257    // The container persists; the toast content is cleaned up258    const root = document.querySelector('#peach-passkey-toast-root');259    expect(root).toBeTruthy();260  });261});