import { describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClientProvider } from '@tanstack/react-query'; import { PasskeysSection } from '@/pages/settings/PasskeysSection'; import { base64URLToBuffer, bufferToBase64URL, createPasskey, getPasskeyAssertion, isPasskeySupported, WebAuthnClientError, } from '@/lib/webauthn'; import { apiError, createTestQueryClient, findRequest, installFetch, json, renderApp, } from './helpers'; /** Кодирует строку в base64url — как это делает браузер для JSON-ответа. */ function base64URL(value: string): string { return bufferToBase64URL(new TextEncoder().encode(value).buffer); } /** Подставной PublicKeyCredential: только то, что читает обёртка. */ function fakeCredential(overrides: Record = {}) { return { id: 'credential-id', rawId: new TextEncoder().encode('credential-id').buffer, type: 'public-key', authenticatorAttachment: 'platform', getClientExtensionResults: () => ({}), response: { clientDataJSON: new TextEncoder().encode('{"type":"webauthn.create"}').buffer, attestationObject: new TextEncoder().encode('attestation').buffer, getTransports: () => ['internal'], }, ...overrides, }; } /** Устанавливает поддержку WebAuthn и мок navigator.credentials. */ function stubCredentials(credentials: { create?: (options?: unknown) => Promise; get?: (options?: unknown) => Promise; }) { vi.stubGlobal('PublicKeyCredential', function PublicKeyCredential() {}); const spies = { create: vi.fn(credentials.create ?? (() => Promise.resolve(null))), get: vi.fn(credentials.get ?? (() => Promise.resolve(null))), }; Object.defineProperty(navigator, 'credentials', { value: spies, configurable: true }); return spies; } describe('обёртка WebAuthn', () => { it('не поддерживается без PublicKeyCredential', () => { vi.stubGlobal('PublicKeyCredential', undefined); expect(isPasskeySupported()).toBe(false); }); it('переводит base64url в ArrayBuffer и обратно', () => { const buffer = base64URLToBuffer('aGVsbG8'); expect(new TextDecoder().decode(buffer)).toBe('hello'); expect(bufferToBase64URL(buffer)).toBe('aGVsbG8'); }); it('создаёт ключ и отдаёт ответ в формате сервера', async () => { const spies = stubCredentials({ create: () => Promise.resolve(fakeCredential()) }); const result = await createPasskey({ challenge: base64URL('challenge-value'), rp: { id: 'gl.mhspx.su', name: 'glchat' }, user: { id: base64URL('user-handle'), name: 'alice', displayName: 'Alice' }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }], excludeCredentials: [{ id: base64URL('old'), type: 'public-key', transports: ['internal'] }], }); expect(result.type).toBe('public-key'); expect(result.response.attestationObject).toBe(base64URL('attestation')); expect(result.response.transports).toEqual(['internal']); // challenge уходит в браузер как ArrayBuffer, а не строкой. const options = spies.create.mock.calls[0]?.[0] as { publicKey: { challenge: ArrayBuffer; excludeCredentials: { id: ArrayBuffer }[] }; }; expect(options.publicKey.challenge).toBeInstanceOf(ArrayBuffer); expect(options.publicKey.excludeCredentials[0]?.id).toBeInstanceOf(ArrayBuffer); }); it('возвращает понятный код при отмене пользователем', async () => { stubCredentials({ create: () => Promise.reject(Object.assign(new Error('cancel'), { name: 'NotAllowedError' })), }); await expect( createPasskey({ challenge: base64URL('challenge'), rp: { id: 'gl.mhspx.su', name: 'glchat' }, user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }], }), ).rejects.toMatchObject({ code: 'cancelled' }); }); it('сообщает об отсутствии поддержки до обращения к браузеру', async () => { await expect( createPasskey({ challenge: base64URL('challenge'), rp: { id: 'gl.mhspx.su', name: 'glchat' }, user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }], }), ).rejects.toBeInstanceOf(WebAuthnClientError); }); it('подписывает challenge и передаёт userHandle', async () => { const assertion = { id: 'credential-id', rawId: new TextEncoder().encode('credential-id').buffer, type: 'public-key', authenticatorAttachment: null, getClientExtensionResults: () => ({}), response: { clientDataJSON: new TextEncoder().encode('{"type":"webauthn.get"}').buffer, authenticatorData: new TextEncoder().encode('auth-data').buffer, signature: new TextEncoder().encode('signature').buffer, userHandle: new TextEncoder().encode('handle').buffer, }, }; const spies = stubCredentials({ get: () => Promise.resolve(assertion) }); const result = await getPasskeyAssertion({ challenge: base64URL('challenge') }); expect(result.response.userHandle).toBe(base64URL('handle')); expect(result.authenticatorAttachment).toBeUndefined(); expect(spies.get).toHaveBeenCalledTimes(1); }); }); /** Рендер раздела настроек с изолированным QueryClient. */ function renderSection(ui: React.ReactElement) { const queryClient = createTestQueryClient(); return render({ui}); } describe('раздел «Ключи доступа»', () => { it('без поддержки WebAuthn показывает понятное сообщение', async () => { vi.stubGlobal('PublicKeyCredential', undefined); installFetch([]); renderSection(); expect(await screen.findByTestId('passkeys-unsupported')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Создать ключ' })).toBeNull(); }); it('добавляет ключ: step-up, ответ аутентификатора и обновление списка', async () => { stubCredentials({ create: () => Promise.resolve(fakeCredential()) }); const fetchMock = installFetch([ { match: '/api/v1/auth/passkeys/register/begin', method: 'POST', response: () => json({ options: { publicKey: { challenge: base64URL('challenge'), rp: { id: 'gl.mhspx.su', name: 'glchat' }, user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }], }, }, ceremony: 'ceremony-1', expires_in_sec: 300, }), }, { match: '/api/v1/auth/passkeys/register/finish', method: 'POST', response: () => json({ passkey: { id: 'pk-1', name: 'Ноутбук', created_at: '2026-09-26T10:00:00Z', sign_count: 0, }, }), }, { match: '/api/v1/auth/passkeys', response: () => json({ passkeys: [], supported: true }), }, ]); renderSection(); const visitor = userEvent.setup(); await visitor.type(await screen.findByLabelText('Название ключа'), 'Ноутбук'); await visitor.type(screen.getByLabelText('Ваш пароль'), 'super-secret-1'); await visitor.click(screen.getByRole('button', { name: 'Создать ключ' })); await waitFor(() => { expect(findRequest(fetchMock, { url: 'register/finish', method: 'POST' })).toBeDefined(); }); const finish = findRequest(fetchMock, { url: 'register/finish', method: 'POST' }); const body = finish?.body as { ceremony: string; name: string; step_up_password: string; credential: { response: { attestationObject: string } }; }; expect(body.ceremony).toBe('ceremony-1'); expect(body.name).toBe('Ноутбук'); expect(body.step_up_password).toBe('super-secret-1'); expect(body.credential.response.attestationObject).toBe(base64URL('attestation')); // Пароль уходит и на begin: сервер требует свежий step-up. const begin = findRequest(fetchMock, { url: 'register/begin', method: 'POST' }); expect(begin?.body).toEqual({ password: 'super-secret-1' }); expect(await screen.findByText('Ключ «Ноутбук» добавлен.')).toBeVisible(); }); it('отмена создания ключа показывается человеческим текстом', async () => { stubCredentials({ create: () => Promise.reject(Object.assign(new Error('x'), { name: 'NotAllowedError' })), }); installFetch([ { match: '/api/v1/auth/passkeys/register/begin', method: 'POST', response: () => json({ options: { publicKey: { challenge: base64URL('challenge'), rp: { id: 'gl.mhspx.su', name: 'glchat' }, user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }], }, }, ceremony: 'ceremony-1', expires_in_sec: 300, }), }, { match: '/api/v1/auth/passkeys', response: () => json({ passkeys: [], supported: true }) }, ]); renderSection(); const visitor = userEvent.setup(); await visitor.type(await screen.findByLabelText('Название ключа'), 'Ключ'); await visitor.type(screen.getByLabelText('Ваш пароль'), 'super-secret-1'); await visitor.click(screen.getByRole('button', { name: 'Создать ключ' })); expect(await screen.findByTestId('passkeys-error')).toHaveTextContent( 'Создание ключа отменено', ); }); it('серверная ошибка step-up показывается как есть', async () => { stubCredentials({ create: () => Promise.resolve(fakeCredential()) }); installFetch([ { match: '/api/v1/auth/passkeys/register/begin', method: 'POST', response: () => apiError('auth.step_up_required', 403), }, { match: '/api/v1/auth/passkeys', response: () => json({ passkeys: [], supported: true }) }, ]); renderSection(); const visitor = userEvent.setup(); await visitor.type(await screen.findByLabelText('Название ключа'), 'Ключ'); await visitor.type(screen.getByLabelText('Ваш пароль'), 'wrong'); await visitor.click(screen.getByRole('button', { name: 'Создать ключ' })); expect(await screen.findByText(/Подтвердите личность/)).toBeVisible(); }); }); const instanceOpen = { name: 'glchat-test', version: 'v0.1.0-test', registration_enabled: true, allow_guild_creation: true, max_guilds_per_user: 5, max_members_per_guild: 100, max_message_length: 2000, main_guild_id: 'g-main', user_count: 1, guild_count: 1, }; const metaWithPasskeys = { name: 'glchat-test', version: 'v0.1.0-test', commit: 'deadbee', build_date: '2026-09-26T00:00:00Z', api_version: 'v1', base_url: 'https://gl.mhspx.su', files_url: 'https://files.gl.mhspx.su', gateway_url: 'wss://gl.mhspx.su/gateway', rtc_path: '/rtc', max_upload_size: 26214400, features: { registration_enabled: true, anti_bot_enabled: false, voice_enabled: false, web_push_enabled: false, oauth_enabled: true, passkeys_enabled: true, oauth_providers: ['github'], }, }; describe('страница входа: ключи и внешние провайдеры', () => { it('входит по ключу и переводит в приложение', async () => { const assertion = { id: 'credential-id', rawId: new TextEncoder().encode('credential-id').buffer, type: 'public-key', authenticatorAttachment: null, getClientExtensionResults: () => ({}), response: { clientDataJSON: new TextEncoder().encode('{}').buffer, authenticatorData: new TextEncoder().encode('auth-data').buffer, signature: new TextEncoder().encode('signature').buffer, userHandle: null, }, }; stubCredentials({ get: () => Promise.resolve(assertion) }); const user = { id: 'user-1', username: 'alice', display_name: 'Alice', onboarding_completed: true, totp_enabled: false, badges: [], }; const fetchMock = installFetch([ { match: '/api/v1/meta', response: () => json(metaWithPasskeys) }, { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, { match: '/api/v1/auth/passkeys/login/begin', method: 'POST', response: () => json({ options: { publicKey: { challenge: base64URL('challenge') } }, ceremony: 'ceremony-login', expires_in_sec: 300, }), }, { match: '/api/v1/auth/passkeys/login/finish', method: 'POST', response: () => json({ user }), }, { match: '/api/v1/auth/oauth/providers', response: () => json({ providers: [{ id: 'github', name: 'GitHub' }], redirect_url: 'https://x' }), }, { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, ]); const { router } = renderApp('/login'); const visitor = userEvent.setup(); // Кнопка внешнего провайдера нарисована по списку с сервера. expect(await screen.findByTestId('oauth-github')).toBeVisible(); await visitor.click(await screen.findByTestId('passkey-login')); await waitFor(() => { expect(router.state.location.pathname).toBe('/app/empty'); }); const finish = findRequest(fetchMock, { url: 'passkeys/login/finish', method: 'POST' }); expect((finish?.body as { ceremony: string }).ceremony).toBe('ceremony-login'); }); it('ошибка возврата от провайдера показывается на странице входа', async () => { installFetch([ { match: '/api/v1/meta', response: () => json({ ...metaWithPasskeys, features: { ...metaWithPasskeys.features, passkeys_enabled: false }, }), }, { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, { match: '/api/v1/auth/oauth/providers', response: () => json({ providers: [], redirect_url: 'https://x' }), }, ]); renderApp('/login?oauth_error=provider_not_configured'); expect(await screen.findByTestId('oauth-error')).toHaveTextContent( 'Этот способ входа выключен на инстансе', ); }); it('без поддержки браузера кнопка входа по ключу не показывается', async () => { vi.stubGlobal('PublicKeyCredential', undefined); installFetch([ { match: '/api/v1/meta', response: () => json(metaWithPasskeys) }, { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, { match: '/api/v1/auth/oauth/providers', response: () => json({ providers: [], redirect_url: 'https://x' }), }, ]); renderApp('/login'); await screen.findByLabelText('Почта'); expect(screen.queryByTestId('passkey-login')).toBeNull(); }); });