2026-09-19 22:03:46 +03:00
|
|
|
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'] }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
renderApp('/settings/account/security');
|
2026-09-19 22:03:46 +03:00
|
|
|
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);
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
const { router } = renderApp('/settings/account/security');
|
2026-09-19 22:03:46 +03:00
|
|
|
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 }) },
|
|
|
|
|
]);
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
renderApp('/settings/account/security');
|
2026-09-19 22:03:46 +03:00
|
|
|
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: 'Алиса' } }),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
renderApp('/settings/account/profile');
|
2026-09-19 22:03:46 +03:00
|
|
|
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 }) }]);
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
renderApp('/settings/account/profile');
|
2026-09-19 22:03:46 +03:00
|
|
|
|
|
|
|
|
expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible();
|
|
|
|
|
expect(screen.queryByRole('link', { name: 'Инстанс' })).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-19 22:03:46 +03:00
|
|
|
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',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
2026-09-19 23:36:16 +03:00
|
|
|
renderApp('/settings/account/instance');
|
2026-09-19 22:03:46 +03:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|