import { describe, expect, it, vi } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { apiError, findRequest, installFetch, json, makeUser, recordedRequests, renderApp, } from './helpers'; const user = makeUser(); const sessions = [ { id: 's-1', user_agent: 'Firefox on Linux', ip: '10.0.0.2', created_at: '2026-09-01T10:00:00Z', last_seen: '2026-09-19T10:00:00Z', current: true, }, { id: 's-2', user_agent: 'Chrome on macOS', ip: '10.0.0.3', created_at: '2026-09-02T10:00:00Z', last_seen: '2026-09-18T10:00:00Z', current: false, }, ]; describe('настройки: безопасность', () => { it('включение 2FA показывает секрет и коды восстановления', async () => { const fetchMock = installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/auth/sessions', response: () => json({ sessions }) }, { match: '/api/v1/auth/2fa/setup', method: 'POST', response: () => json({ secret: 'JBSWY3DPEHPK3PXP', otpauth_url: 'otpauth://totp/glchat:alice?secret=X', // QR-код рисует сервер и отдаёт data-URI: внешние сервисы не нужны. qr_png: 'data:image/png;base64,iVBORw0KGgo=', }), }, { match: '/api/v1/auth/2fa/enable', method: 'POST', response: () => json({ recovery_codes: ['aaaa-bbbb', 'cccc-dddd'] }), }, ]); renderApp('/settings/account/security'); const visitor = userEvent.setup(); await visitor.click(await screen.findByRole('button', { name: 'Включить 2FA' })); expect(await screen.findByTestId('totp-secret')).toHaveTextContent('JBSWY3DPEHPK3PXP'); expect(screen.getByAltText('QR-код для настройки 2FA')).toBeVisible(); await visitor.type(screen.getByLabelText('Код из приложения'), '123456'); await visitor.click(screen.getByRole('button', { name: 'Включить' })); const codes = await screen.findByTestId('recovery-codes'); expect(codes).toHaveTextContent('aaaa-bbbb'); expect(codes).toHaveTextContent('cccc-dddd'); expect(screen.getByText('2FA включена. Сохраните коды восстановления.')).toBeVisible(); expect(findRequest(fetchMock, { url: '/auth/2fa/enable', method: 'POST' })?.body).toEqual({ code: '123456', }); }); it('logout-all вызывается по кнопке и уводит на /login', async () => { const fetchMock = installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/auth/sessions', response: () => json({ sessions }) }, { match: '/api/v1/auth/logout-all', method: 'POST', response: () => json({ ok: true }) }, ]); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); const { router } = renderApp('/settings/account/security'); const visitor = userEvent.setup(); expect(await screen.findByTestId('sessions-list')).toHaveTextContent('Firefox on Linux'); await visitor.click(screen.getByRole('button', { name: 'Выйти на всех устройствах' })); await waitFor(() => { expect(router.state.location.pathname).toBe('/login'); }); expect(confirm).toHaveBeenCalled(); expect( recordedRequests(fetchMock).some( (request) => request.url.includes('/auth/logout-all') && request.method === 'POST', ), ).toBe(true); }); it('смена пароля требует step-up и повторяется после подтверждения', async () => { let passwordAttempts = 0; const fetchMock = installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/auth/sessions', response: () => json({ sessions }) }, { match: '/api/v1/users/@me/password', method: 'POST', response: () => { passwordAttempts += 1; return passwordAttempts === 1 ? apiError('auth.step_up_required', 403) : json({ ok: true }); }, }, { match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) }, ]); renderApp('/settings/account/security'); const visitor = userEvent.setup(); await visitor.type(await screen.findByLabelText('Текущий пароль'), 'old-password-1'); await visitor.type(screen.getByLabelText('Новый пароль'), 'new-password-1'); await visitor.type(screen.getByLabelText('Повторите новый пароль'), 'new-password-1'); await visitor.click(screen.getByRole('button', { name: 'Сменить пароль' })); // Сервер ответил auth.step_up_required — появилась форма подтверждения. expect(await screen.findByText('Подтвердите личность')).toBeVisible(); await visitor.type(screen.getByLabelText('Ваш пароль'), 'old-password-1'); await visitor.click(screen.getByRole('button', { name: 'Подтвердить' })); expect(await screen.findByText('Пароль изменён.')).toBeVisible(); expect( recordedRequests(fetchMock).filter( (request) => request.url.includes('/users/@me/password') && request.method === 'POST', ), ).toHaveLength(2); }); it('профиль сохраняется через PATCH /users/@me', async () => { const fetchMock = installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/users/@me', method: 'PATCH', response: () => json({ user: { ...user, display_name: 'Алиса' } }), }, ]); renderApp('/settings/account/profile'); const visitor = userEvent.setup(); const displayName = await screen.findByLabelText('Отображаемое имя'); await visitor.clear(displayName); await visitor.type(displayName, 'Алиса'); await visitor.click(screen.getByRole('button', { name: 'Сохранить' })); expect(await screen.findByText('Профиль сохранён.')).toBeVisible(); const patchCall = findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' }); expect(patchCall?.body).toMatchObject({ display_name: 'Алиса' }); }); it('вкладка «Инстанс» скрыта от не-администратора', async () => { installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]); renderApp('/settings/account/profile'); expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible(); expect(screen.queryByRole('link', { name: 'Инстанс' })).toBeNull(); }); it('вверху настроек — две соседние кнопки-переключателя', async () => { installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, ]); const { router } = renderApp('/settings/account/profile'); const account = await screen.findByRole('link', { name: 'Настройки сервера' }); const servers = screen.getByRole('link', { name: 'Мои серверы' }); expect(account).toHaveAttribute('aria-current', 'page'); expect(servers).not.toHaveAttribute('aria-current'); expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-0'); await userEvent.click(servers); await waitFor(() => { expect(router.state.location.pathname).toBe('/settings/servers'); }); expect(screen.getByRole('link', { name: 'Мои серверы' })).toHaveAttribute( 'aria-current', 'page', ); // Подложка переключателя уезжает ко второй кнопке без перезагрузки. expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-full'); }); it.each([ ['/settings', '/settings/account/profile'], ['/settings/profile', '/settings/account/profile'], ['/settings/security', '/settings/account/security'], ['/settings/appearance', '/settings/account/appearance'], ['/settings/instance', '/settings/account/instance'], ])('старый путь %s редиректит на %s', async (from, to) => { installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]); const { router } = renderApp(from); await waitFor(() => { expect(router.state.location.pathname).toBe(to); }); }); it('администратор видит данные инстанса', async () => { const admin = makeUser({ is_instance_admin: true }); installFetch([ { match: '/api/v1/users/@me', response: () => json({ user: admin }) }, { match: '/api/v1/instance/settings', response: () => json({ settings: { motd: 'привет' } }), }, { match: '/api/v1/instance/guilds', response: () => json({ guilds: [{ id: 'g-1', name: 'Main', member_count: 3, owner_id: 'u', is_main: true }], }), }, { match: '/api/v1/instance/users', response: () => json({ users: [ { id: 'u-1', username: 'alice', display_name: 'Alice', is_instance_admin: true, created_at: '2026-09-01T00:00:00Z', }, ], }), }, { match: '/api/v1/instance/audit', response: () => json({ entries: [ { id: 'a-1', actor_id: 'u-1', action: 'guild.create', created_at: '2026-09-01T00:00:00Z', }, ], }), }, ]); renderApp('/settings/account/instance'); expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main'); expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice'); expect(screen.getByTestId('instance-audit')).toHaveTextContent('guild.create'); expect(screen.getByText('motd')).toBeVisible(); }); });