passkey-toast.test.ts

11.9 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  });220221  it('PB-26-501: does not publish the toast token to page message listeners when the toast closes', async () => {222    const originalAttachShadow = Element.prototype.attachShadow;223    const attachSpy = vi.spyOn(Element.prototype, 'attachShadow').mockImplementation(function (224      this: Element,225      init: ShadowRootInit,226    ) {227      return originalAttachShadow.call(this, { ...init, mode: 'open' });228    });229    const observedMessages: unknown[] = [];230    const pageListener = (event: MessageEvent) => observedMessages.push(event.data);231    window.addEventListener('message', pageListener);232233    try {234      sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });235      const root = document.querySelector<HTMLElement>('#peach-passkey-toast-root');236      const denyButton = root?.shadowRoot?.querySelector<HTMLButtonElement>('.peach-passkey-toast-btn--deny');237      expect(denyButton).toBeTruthy();238239      denyButton?.click();240      await new Promise((resolve) => setTimeout(resolve, 250));241      await new Promise((resolve) => setTimeout(resolve, 0));242243      expect(observedMessages).not.toContainEqual(expect.objectContaining({244        source: 'peach-passkey-toast',245        token: mockPayload.toastToken,246      }));247    } finally {248      window.removeEventListener('message', pageListener);249      attachSpy.mockRestore();250    }251  });252253  it('keeps a replacement toast dismissible when the previous toast finishes closing', async () => {254    const originalAttachShadow = Element.prototype.attachShadow;255    const attachSpy = vi.spyOn(Element.prototype, 'attachShadow').mockImplementation(function (256      this: Element,257      init: ShadowRootInit,258    ) {259      return originalAttachShadow.call(this, { ...init, mode: 'open' });260    });261262    try {263      sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });264      const root = document.querySelector<HTMLElement>('#peach-passkey-toast-root');265      root?.shadowRoot?.querySelector<HTMLButtonElement>('.peach-passkey-toast-btn--deny')?.click();266267      sendToContentScript(contentListener, {268        type: 'SHOW_PASSKEY_TOAST',269        payload: { ...mockPayload, requestId: 'req-2', toastToken: 'replacement-token' },270      });271      await new Promise((resolve) => setTimeout(resolve, 250));272      sendToContentScript(contentListener, { type: 'DISMISS_PASSKEY_TOAST' });273274      expect(root?.shadowRoot?.querySelector('.peach-passkey-toast')).toBeNull();275    } finally {276      attachSpy.mockRestore();277    }278  });279});280281// ── Autofill blocking while toast active ──────────────────────────────────────282283describe('autofill blocking while toast active', () => {284  let contentListener: MessageListener;285286  beforeEach(async () => {287    vi.resetModules();288    document.body.innerHTML = '';289290    const chromeMock = {291      runtime: {292        sendMessage: vi.fn(async () => ({ ok: true })),293        onMessage: {294          addListener: vi.fn((fn: MessageListener) => {295            contentListener = fn;296          }),297        },298      },299      storage: {300        local: {301          get: vi.fn(async () => ({})),302          set: vi.fn(async () => {}),303        },304      },305    };306    (globalThis as { chrome?: unknown }).chrome = chromeMock;307    await import('./inject');308  });309310  it('toast is shown after SHOW_PASSKEY_TOAST', () => {311    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });312    expect(document.querySelector('#peach-passkey-toast-root')).toBeTruthy();313  });314315  it('toast is cleared after DISMISS_PASSKEY_TOAST', () => {316    sendToContentScript(contentListener, { type: 'SHOW_PASSKEY_TOAST', payload: mockPayload });317    sendToContentScript(contentListener, { type: 'DISMISS_PASSKEY_TOAST' });318    // The container persists; the toast content is cleaned up319    const root = document.querySelector('#peach-passkey-toast-root');320    expect(root).toBeTruthy();321  });322});