import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; const app = { authUser: { id: 'u1', email: 'a@x.test', loginAlerts: false }, setAuthUser: vi.fn() }; vi.mock('../../context/AppContext.js', () => ({ useApp: () => app })); const webauthn = vi.hoisted(() => ({ browserSupportsWebAuthn: vi.fn(() => true), startRegistration: vi.fn(), startAuthentication: vi.fn() })); vi.mock('@simplewebauthn/browser', () => webauthn); import PasskeysSection from './PasskeysSection.jsx'; import DevicesSection, { whenText } from './DevicesSection.jsx'; import { signInWithPasskey } from '../../utils/passkeys.js'; const routes = {}; beforeEach(() => { Object.keys(routes).forEach((k) => delete routes[k]); app.setAuthUser.mockClear(); webauthn.browserSupportsWebAuthn.mockReturnValue(true); vi.stubGlobal('fetch', vi.fn(async (url, opts) => { const key = `${opts?.method ?? 'GET'} ${String(url).replace(/^.*\/api/, '')}`; const r = routes[key] ?? { status: 404, body: { error: `no route ${key}` } }; return { ok: (r.status ?? 200) < 400, status: r.status ?? 200, json: async () => r.body }; })); }); afterEach(() => vi.unstubAllGlobals()); const passkey = { id: 'k1', name: 'My phone', createdAt: 1_700_000_000_000, lastUsedAt: null }; test('lists passkeys and adds one through the device prompt', async () => { routes['GET /passkeys'] = { body: [passkey] }; routes['POST /passkeys/options'] = { body: { challenge: 'c' } }; routes['POST /passkeys'] = { body: [passkey, { ...passkey, id: 'k2', name: 'Laptop' }] }; webauthn.startRegistration.mockResolvedValue({ id: 'k2' }); const user = userEvent.setup(); render(); expect(await screen.findByText('My phone')).toBeInTheDocument(); expect(screen.getByText(/not used yet/)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /Add a passkey on this device/ })); expect(await screen.findByText('Laptop')).toBeInTheDocument(); expect(webauthn.startRegistration).toHaveBeenCalledWith({ optionsJSON: { challenge: 'c' } }); }); test('says so when the device prompt is cancelled', async () => { routes['GET /passkeys'] = { body: [] }; routes['POST /passkeys/options'] = { body: { challenge: 'c' } }; webauthn.startRegistration.mockRejectedValue(Object.assign(new Error('x'), { name: 'NotAllowedError' })); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: /Add a passkey/ })); expect(await screen.findByRole('alert')).toHaveTextContent('cancelled or timed out'); }); test('renames and removes a passkey', async () => { routes['GET /passkeys'] = { body: [passkey] }; routes['PATCH /passkeys/k1'] = { body: [{ ...passkey, name: 'Pixel' }] }; routes['DELETE /passkeys/k1'] = { body: [] }; vi.spyOn(window, 'confirm').mockReturnValue(true); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: 'Rename' })); const input = screen.getByLabelText('Passkey name'); await user.clear(input); await user.type(input, 'Pixel'); await user.click(screen.getByRole('button', { name: 'Save' })); expect(await screen.findByText('Pixel')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Remove' })); await waitFor(() => expect(screen.queryByText('Pixel')).not.toBeInTheDocument()); }); test('hides the button where passkeys cannot work', async () => { webauthn.browserSupportsWebAuthn.mockReturnValue(false); routes['GET /passkeys'] = { body: [] }; render(); expect(await screen.findByText(/can’t use passkeys/)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Add a passkey/ })).not.toBeInTheDocument(); }); test('sign-in with a passkey asks the server, then the device, then the server again', async () => { routes['POST /auth/passkey/options'] = { body: { challenge: 'c' } }; routes['POST /auth/passkey/verify'] = { body: { id: 'u1', email: 'a@x.test' } }; webauthn.startAuthentication.mockResolvedValue({ id: 'k1' }); expect(await signInWithPasskey()).toMatchObject({ ok: true, data: { email: 'a@x.test' } }); webauthn.startAuthentication.mockRejectedValue(Object.assign(new Error('x'), { name: 'NotAllowedError' })); expect(await signInWithPasskey()).toMatchObject({ ok: false, cancelled: true }); }); test('shows devices, signs one out, and signs out the rest', async () => { const devices = [ { id: 'd1', current: true, label: 'Safari on macOS', method: 'password', ip: '1.1.1.1', lastSeen: Date.now() }, { id: 'd2', current: false, label: 'Chrome on Windows', method: 'passkey', ip: '2.2.2.2', lastSeen: Date.now() - 3 * 86400000 }, ]; routes['GET /sessions'] = { body: devices }; routes['DELETE /sessions/d2'] = { body: { ok: true } }; vi.spyOn(window, 'confirm').mockReturnValue(true); const user = userEvent.setup(); render(); expect(await screen.findByText('Chrome on Windows')).toBeInTheDocument(); expect(screen.getByText('This device')).toBeInTheDocument(); expect(screen.getByText(/signed in with passkey/)).toBeInTheDocument(); routes['GET /sessions'] = { body: [devices[0]] }; await user.click(screen.getByRole('button', { name: 'Sign out' })); await waitFor(() => expect(screen.queryByText('Chrome on Windows')).not.toBeInTheDocument()); expect(screen.queryByRole('button', { name: /Sign out the other/ })).not.toBeInTheDocument(); }); test('the sign-in alert switch reports a server refusal', async () => { routes['GET /sessions'] = { body: [] }; routes['PUT /auth/login-alerts'] = { status: 400, body: { error: 'Set up a notification topic first (under Notifications).' } }; const user = userEvent.setup(); render(); await user.click(await screen.findByRole('checkbox')); expect(await screen.findByRole('alert')).toHaveTextContent('notification topic'); expect(app.setAuthUser).not.toHaveBeenCalled(); routes['PUT /auth/login-alerts'] = { body: { loginAlerts: true } }; await user.click(screen.getByRole('checkbox')); await waitFor(() => expect(app.setAuthUser).toHaveBeenCalledWith(expect.objectContaining({ loginAlerts: true }))); }); test('times read plainly', () => { const now = 1_000_000_000_000; expect(whenText(now - 30_000, now)).toBe('just now'); expect(whenText(now - 5 * 3600_000, now)).toBe('5 hours ago'); expect(whenText(now - 3 * 86400_000, now)).toBe('3 days ago'); });