355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
|
|
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<HTMLElement> {
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
}
|