feat(web): статистика, карточка пользователя и фильтры аудита в админ-панели (Фаза 7)
Новые разделы панели инстанса (AGENT.md 3.2, 7.18): - вкладка «Статистика»: рост пользователей и сообщений по дням, размеры данных, топы серверов; - карточка пользователя из списка: устройства с отзывом, серверы и роли, события безопасности, аудит по пользователю; отзыв устройства и сброс второго фактора подтверждаются step-up; - вкладка «Аудит»: фильтры по действию, актору, цели, серверу и датам, пагинация и ссылка на выгрузку CSV; - вкладка «Серверы»: поиск по названию, фильтр по владельцу и главному серверу, пагинация; - ключи i18n для новых разделов (ru/en, паритет), тесты панели.
This commit is contained in:
@@ -152,7 +152,11 @@ describe('api-модули Фазы 1', () => {
|
||||
]);
|
||||
|
||||
await expect(fetchInstance()).resolves.toEqual({ name: 'glchat' });
|
||||
await expect(fetchAudit(50)).resolves.toEqual([{ id: 'a-1' }]);
|
||||
// Журнал отдаётся вместе с общим числом записей под фильтр (AGENT.md 7.18).
|
||||
await expect(fetchAudit({ limit: 50 })).resolves.toEqual({
|
||||
entries: [{ id: 'a-1' }],
|
||||
total: 1,
|
||||
});
|
||||
expect(recordedRequests(fetchMock).map((request) => request.url)).toContain(
|
||||
'/api/v1/instance/audit?limit=50',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user