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:
2026-09-22 18:59:53 +03:00
parent d2712c4c99
commit 787f822dc0
15 changed files with 850 additions and 55 deletions
+83
View File
@@ -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('настройки: безопасность', () => {