feat(instance): глобальный бан пользователя и поиск в админ-панели
Сервер:
- миграция 00017: таблица instance_bans (причина, автор, дата);
- auth: ErrUserBanned, проверка бана в Login (код user.banned, 403) и в
ResolveSession — забаненный не получает сессию ни по cookie, ни по Bearer,
ни в Gateway, а прежняя сессия удаляется;
- store: BannedAt/BanReason в модели пользователя, BanInstanceUser,
UnbanInstanceUser, IsInstanceBanned, поиск и фильтр в ListUsers,
CountUsersFiltered; мягкое удаление аккаунта убирает и запись о бане;
- API: POST /instance/users/{id}/ban и /unban со step-up (AGENT.md 7.1),
отзыв сессий и SESSION_INVALIDATED, аудит instance.user_ban с причиной и
instance.user_unban; себя и инстанс-админа забанить нельзя;
- GET /instance/users: q (логин и отображаемое имя), banned=true, total.
Клиент:
- панель: поиск, фильтр «только забаненные», бейдж бана с причиной, кнопки
«Забанить» (с причиной) и «Разбанить» через общий шаг подтверждения
личности; i18n ru/en, включая текст ошибки user.banned;
- keepPreviousData в списке пользователей: без этого поле поиска
размонтировалось на первом же символе и набор обрывался.
Тесты: 4 Go-теста (бан блокирует вход и сессии, защита админов, поиск,
уборка бана при удалении), web-тест панели, живая проверка на стенде 19/19.
This commit is contained in:
@@ -530,8 +530,10 @@ describe('настройки: аватар', () => {
|
||||
display_name: 'Alice',
|
||||
is_instance_admin: true,
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
banned: false,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
||||
@@ -547,6 +549,87 @@ describe('настройки: аватар', () => {
|
||||
expect(await within(dialog).findByTestId('instance-user-id-u-42')).toHaveTextContent('u-42');
|
||||
expect(within(dialog).getByText(/ID пользователей видны только администратору/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('глобальный бан требует причину и step-up, разбан возвращает доступ', async () => {
|
||||
const other = {
|
||||
id: 'u-7',
|
||||
username: 'bob',
|
||||
display_name: 'Bob',
|
||||
is_instance_admin: false,
|
||||
created_at: '2026-09-02T00:00:00Z',
|
||||
banned: false,
|
||||
};
|
||||
const bannedUser = {
|
||||
...other,
|
||||
id: 'u-8',
|
||||
username: 'mallory',
|
||||
display_name: 'Mallory',
|
||||
banned: true,
|
||||
ban_reason: 'спам',
|
||||
};
|
||||
const banRequests: { url: string; body: unknown }[] = [];
|
||||
const listUrls: string[] = [];
|
||||
const fetchMock = installFetch(
|
||||
appRoutes(makeUser({ is_instance_admin: true }), [
|
||||
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
|
||||
{ match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
||||
{
|
||||
match: '/api/v1/auth/step-up',
|
||||
method: 'POST',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/instance/users/u-7/ban',
|
||||
method: 'POST',
|
||||
response: (request) => {
|
||||
banRequests.push({ url: request.url, body: request.body });
|
||||
return json({ user: {} });
|
||||
},
|
||||
},
|
||||
{
|
||||
match: '/api/v1/instance/users',
|
||||
response: (request) => {
|
||||
listUrls.push(request.url);
|
||||
return json({ users: [other, bannedUser], total: 2 });
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
await goToSection(dialog, 'Инстанс');
|
||||
await userEvent.click(await within(dialog).findByTestId('instance-tab-users'));
|
||||
|
||||
// Забаненный помечен, у админа и себя кнопка бана недоступна.
|
||||
expect(await within(dialog).findByTestId('instance-user-banned-u-8')).toHaveTextContent(
|
||||
'Забанен',
|
||||
);
|
||||
expect(within(dialog).getByTestId('instance-user-ban-u-7')).toBeEnabled();
|
||||
|
||||
// Бан: сначала причина, затем подтверждение личности.
|
||||
await userEvent.click(within(dialog).getByTestId('instance-user-ban-u-7'));
|
||||
const reasonForm = await within(dialog).findByTestId('instance-user-ban-form-u-7');
|
||||
await userEvent.type(within(reasonForm).getByLabelText('Причина бана'), 'флуд');
|
||||
await userEvent.click(within(reasonForm).getByRole('button', { name: 'Забанить' }));
|
||||
|
||||
const stepUpForm = await within(dialog).findByTestId('instance-step-up');
|
||||
await userEvent.type(within(stepUpForm).getByLabelText('Ваш пароль'), 'correct-horse-battery');
|
||||
await userEvent.click(within(stepUpForm).getByRole('button', { name: 'Подтвердить' }));
|
||||
|
||||
await waitFor(() => expect(banRequests).toHaveLength(1));
|
||||
expect(banRequests[0]?.url).toContain('/instance/users/u-7/ban');
|
||||
expect(banRequests[0]?.body).toEqual({ reason: 'флуд' });
|
||||
|
||||
// Поиск уходит на сервер параметром q, фильтр — banned=true.
|
||||
const search = within(dialog).getByLabelText('Поиск');
|
||||
await userEvent.type(search, 'bob');
|
||||
await waitFor(() => expect(listUrls.some((url) => url.includes('q=bob'))).toBe(true));
|
||||
await userEvent.click(within(dialog).getByTestId('instance-users-banned-only'));
|
||||
await waitFor(() => expect(listUrls.some((url) => url.includes('banned=true'))).toBe(true));
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('настройки: безопасность', () => {
|
||||
|
||||
Reference in New Issue
Block a user