import { describe, expect, it } from 'vitest'; import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { apiError, installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers'; /** Ответ GET /instance/stats в формате ручки (AGENT.md 7.18). */ function statsPayload() { return { stats: { users: { total: 42, admins: 2, banned: 1, new_7_days: 5, new_30_days: 17, active_24h: 9, daily: [ { date: '2026-09-25', count: 2 }, { date: '2026-09-26', count: 3 }, ], }, messages: { total: 1500, today: 40, week: 300, month: 900, daily: [ { date: '2026-09-25', count: 10 }, { date: '2026-09-26', count: 40 }, ], }, files: { count: 12, bytes: 3 * 1024 * 1024 }, guilds: 4, invites: 3, bans: 1, database_bytes: 2 * 1024 * 1024, storage_bytes: 5 * 1024 * 1024, top_guilds: [{ id: 'g-1', name: 'Альфа', members: 12, messages: 400 }], busiest_guilds: [{ id: 'g-2', name: 'Бета', members: 5, messages: 900 }], }, }; } /** Карточка пользователя GET /instance/users/{id}. */ function userCardPayload() { return { user: { id: 'u-1', username: 'card_target', display_name: 'Цель', is_instance_admin: false, badges: [], created_at: '2026-09-20T10:00:00Z', banned: false, }, totp_enabled: true, passkeys: 1, sessions: [ { id: 's-1', user_agent: 'Тестовый браузер', ip: '127.0.0.1', created_at: '2026-09-26T09:00:00Z', last_seen: '2026-09-26T10:00:00Z', expires_at: '2026-10-26T09:00:00Z', stepped_up: false, }, ], guilds: [ { guild_id: 'g-1', guild_name: 'Альфа', nickname: 'гость', joined_at: '2026-09-21T10:00:00Z', roles: [{ id: 'r-1', name: 'Модератор', color: 16711680 }], }, ], security_events: [ { id: 'e-1', type: 'login', ip: '127.0.0.1', created_at: '2026-09-26T09:00:00Z' }, ], audit: [ { id: 'a-1', action: 'instance.user_ban', target_type: 'user', target_id: 'u-1', created_at: '2026-09-25T09:00:00Z', }, ], audit_total: 1, }; } function appRoutes(extra: FetchRoute[]): FetchRoute[] { return [ ...extra, { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/users/@me', response: () => json({ user: makeUser({ is_instance_admin: true }) }), }, { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, ]; } /** openInstance открывает настройки и переходит на вкладку «Инстанс». */ async function openInstance(): Promise { const gear = await screen.findByLabelText('Настройки пользователя'); await userEvent.click(gear); const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); await userEvent.click(await within(dialog).findByRole('button', { name: 'Инстанс' })); return dialog; } describe('расширенная админ-панель инстанса', () => { it('показывает статистику роста, хранилища и топы серверов', async () => { installFetch( appRoutes([ { match: '/api/v1/instance/stats', response: () => json(statsPayload()) }, { match: '/api/v1/instance/settings', response: () => json({ settings: {} }) }, ]), ); renderApp('/app/empty'); const dialog = await openInstance(); await userEvent.click(await within(dialog).findByTestId('instance-tab-stats')); expect(await within(dialog).findByTestId('instance-stats-users')).toHaveTextContent('42'); expect(within(dialog).getByTestId('instance-stats-users-7')).toHaveTextContent('5'); expect(within(dialog).getByTestId('instance-stats-messages')).toHaveTextContent('1 500'); expect(within(dialog).getByTestId('instance-stats-storage')).toHaveTextContent('5 MB'); expect(within(dialog).getByTestId('instance-stats-guilds')).toHaveTextContent('4'); // Полоски роста: по одной на день из ответа. const daily = within(dialog).getByTestId('instance-stats-messages-daily'); expect(within(daily).getAllByRole('listitem')).toHaveLength(2); expect(within(dialog).getByTestId('instance-stats-top-guilds')).toHaveTextContent('Альфа'); expect(within(dialog).getByTestId('instance-stats-busiest-guilds')).toHaveTextContent('Бета'); }); it('фильтрует аудит, листает страницы и отдаёт ссылку на экспорт', async () => { const requests: string[] = []; installFetch( appRoutes([ { match: '/api/v1/instance/audit/actions', response: () => json({ actions: ['instance.user_ban', 'instance.settings_update'] }), }, { match: '/api/v1/instance/audit', response: ({ url }) => { requests.push(url); return json({ entries: [ { id: 'a-1', action: 'instance.user_ban', actor_id: 'u-9', target_type: 'user', target_id: 'u-1', reason: 'спам', created_at: '2026-09-25T09:00:00Z', }, ], total: 120, }); }, }, ]), ); renderApp('/app/empty'); const dialog = await openInstance(); await userEvent.click(await within(dialog).findByTestId('instance-tab-audit')); const list = await within(dialog).findByTestId('instance-audit'); expect(list).toHaveTextContent('instance.user_ban'); expect(list).toHaveTextContent('спам'); expect(within(dialog).getByTestId('instance-audit-total')).toHaveTextContent('120'); // Фильтр по действию уходит в запрос. await userEvent.selectOptions( within(dialog).getByTestId('instance-audit-action'), 'instance.user_ban', ); await waitFor(() => { expect(requests.some((url) => url.includes('action=instance.user_ban'))).toBe(true); }); // Ссылка на выгрузку несёт тот же фильтр. expect(within(dialog).getByTestId('instance-audit-export')).toHaveAttribute( 'href', expect.stringContaining('action=instance.user_ban'), ); // Пагинация: 120 записей по 50 — три страницы, «вперёд» уходит на offset=50. await userEvent.click(within(dialog).getByTestId('instance-audit-next')); await waitFor(() => { expect(requests.some((url) => url.includes('offset=50'))).toBe(true); }); expect(within(dialog).getByTestId('instance-audit-page')).toHaveTextContent('Страница 2 из 3'); }); it('ищет серверы по названию и листает список', async () => { const requests: string[] = []; installFetch( appRoutes([ { match: '/api/v1/instance/guilds', response: ({ url }) => { requests.push(url); return json({ guilds: [ { id: 'g-1', name: 'Альфа', owner_id: 'u-1', owner_name: 'owner', member_count: 3, is_main: false, }, ], total: 60, }); }, }, { match: '/api/v1/instance/users', response: () => json({ users: [], total: 0 }) }, ]), ); renderApp('/app/empty'); const dialog = await openInstance(); await userEvent.click(await within(dialog).findByTestId('instance-tab-guilds')); expect(await within(dialog).findByTestId('instance-guild-g-1')).toHaveTextContent('Альфа'); expect(within(dialog).getByTestId('instance-guilds-total')).toHaveTextContent('60'); await userEvent.type(within(dialog).getByLabelText('Поиск'), 'альф'); await waitFor(() => { expect(requests.some((url) => url.includes('q='))).toBe(true); }); await userEvent.click(within(dialog).getByTestId('instance-guilds-next')); await waitFor(() => { expect(requests.some((url) => url.includes('offset=50'))).toBe(true); }); expect(within(dialog).getByTestId('instance-guilds-page')).toHaveTextContent('Страница 2 из 2'); }); it('открывает карточку пользователя, отзывает устройство и сбрасывает 2FA', async () => { const calls: { url: string; method: string; body: unknown }[] = []; installFetch( appRoutes([ { match: '/api/v1/instance/users', response: () => json({ users: [makeInstanceUser()], total: 1 }), }, { match: '/api/v1/auth/step-up', method: 'POST', response: () => { calls.push({ url: 'step-up', method: 'POST', body: null }); return json({ ok: true }); }, }, { match: '/api/v1/instance/users/u-1/sessions/s-1', method: 'DELETE', response: () => { calls.push({ url: 'revoke', method: 'DELETE', body: null }); // Первый вызов сервер отклоняет: нужна свежая проверка личности // (AGENT.md 7.1), после step-up действие проходит. return calls.filter((call) => call.url === 'revoke').length === 1 ? apiError('auth.step_up_required', 403) : json({ ok: true }); }, }, { match: '/api/v1/instance/users/u-1/reset-2fa', method: 'POST', response: () => { calls.push({ url: 'reset-2fa', method: 'POST', body: null }); return json({ user_id: 'u-1', sessions_revoked: 2 }); }, }, { match: '/api/v1/instance/users/u-1', response: () => json(userCardPayload()), }, ]), ); renderApp('/app/empty'); const dialog = await openInstance(); await userEvent.click(await within(dialog).findByTestId('instance-tab-users')); await userEvent.click(await within(dialog).findByTestId('instance-user-card-u-1')); const card = await within(dialog).findByTestId('instance-user-card'); expect(within(card).getByTestId('instance-user-card-guilds')).toHaveTextContent('Модератор'); expect(within(card).getByTestId('instance-user-card-totp')).toHaveTextContent( 'Второй фактор включён', ); expect(within(card).getByTestId('instance-user-card-events')).toHaveTextContent('login'); expect(within(card).getByTestId('instance-user-card-audit')).toHaveTextContent( 'instance.user_ban', ); // Отзыв устройства — чувствительное действие: сервер требует step-up, // панель показывает форму подтверждения (AGENT.md 7.1). await userEvent.click(within(card).getByTestId('instance-user-card-revoke-s-1')); const stepUpForm = await within(card).findByTestId('instance-user-card-step-up'); expect(stepUpForm).toBeVisible(); await userEvent.type(within(stepUpForm).getByLabelText('Ваш пароль'), 'correct-horse-battery'); await userEvent.click(within(stepUpForm).getByRole('button', { name: 'Подтвердить' })); await waitFor(() => { expect(calls.filter((call) => call.url === 'revoke')).toHaveLength(2); }); expect(calls.some((call) => call.url === 'step-up')).toBe(true); }); it('скрывает сброс второго фактора при выключенном 2FA', async () => { const payload = userCardPayload(); payload.totp_enabled = false; installFetch( appRoutes([ { match: '/api/v1/instance/users', response: () => json({ users: [makeInstanceUser()], total: 1 }), }, { match: '/api/v1/instance/users/u-1', response: () => json(payload) }, ]), ); renderApp('/app/empty'); const dialog = await openInstance(); await userEvent.click(await within(dialog).findByTestId('instance-tab-users')); await userEvent.click(await within(dialog).findByTestId('instance-user-card-u-1')); const card = await within(dialog).findByTestId('instance-user-card'); expect(within(card).getByTestId('instance-user-card-reset-2fa')).toBeDisabled(); expect(within(card).getByTestId('instance-user-card-totp')).toHaveTextContent( 'Второй фактор выключен', ); }); }); /** makeInstanceUser — строка списка пользователей панели. */ function makeInstanceUser() { return { id: 'u-1', username: 'card_target', display_name: 'Цель', is_instance_admin: false, badges: [], created_at: '2026-09-20T10:00:00Z', banned: false, }; }