feat(webhooks): вебхуки комнат — бэкенд, клиент и настройки комнаты

- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
  step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
  avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
  пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
This commit is contained in:
2026-09-21 00:26:50 +03:00
parent 4ea2965892
commit 2e038a1d3f
32 changed files with 2494 additions and 23 deletions
+218
View File
@@ -0,0 +1,218 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
bodyOf,
findRequest,
installFetch,
json,
makeReadySnapshot,
makeUser,
renderApp,
resetStores,
installGatewaySocket,
messagesRoutes,
type FetchRoute,
} from './helpers';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
/**
* Вебхуки комнаты (AGENT.md 7.11): секция «Комната» в настройках — список,
* создание со step-up, пересоздание ссылки, копирование URL и удаление.
*/
const user = makeUser({ is_instance_admin: true });
const channel = { id: 'c-1', name: 'общий', type: 'text' as const, position: 0 };
function webhook(overrides: Record<string, unknown> = {}) {
return {
id: 'w-1',
guild_id: 'g-1',
channel_id: 'c-1',
name: 'Деплой',
token: 'token-1',
created_by: 'user-1',
created_at: '2026-09-20T10:00:00Z',
url: 'https://gl.test/api/v1/webhooks/w-1/token-1',
...overrides,
};
}
function routes(overrides: { list?: unknown[] } = {}): FetchRoute[] {
return [
{
match: '/api/v1/users/@me/guilds',
response: () =>
json({
guilds: [
{
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 1,
my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'],
my_role_ids: [],
},
],
}),
},
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [channel] }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{
match: '/api/v1/guilds/g-1',
response: () =>
json({
guild: {
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 1,
my_role_ids: [],
my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'],
channels: [channel],
roles: [],
},
}),
},
{
match: '/api/v1/channels/c-1/webhooks',
response: () => json({ webhooks: overrides.list ?? [webhook()] }),
},
...messagesRoutes('c-1', []),
];
}
beforeEach(() => {
resetStores();
});
/** Открывает настройки комнаты с готовым списком вебхуков. */
async function openChannelSettings(list?: unknown[]) {
const fetchMock = installFetch(routes(list === undefined ? {} : { list }));
const snapshot = makeReadySnapshot([
{
id: 'g-1',
name: 'Сервер',
is_main: true,
my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'],
channels: [channel],
},
]);
const gateway = installGatewaySocket(snapshot);
renderApp('/app/g-1/c-1');
await gateway.greet();
await screen.findByTestId('message-list');
await userEvent.click(await screen.findByTestId('channel-settings'));
const content = await screen.findByTestId('settings-content');
await within(content).findByTestId('channel-webhooks');
return { fetchMock, content };
}
describe('вебхуки комнаты', () => {
it('показывает список со ссылкой и копирует её', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
const { content } = await openChannelSettings();
const item = within(content).getByTestId('webhook-w-1');
expect(within(item).getByText('Деплой')).toBeVisible();
expect(within(item).getByTestId('webhook-url-w-1')).toHaveValue(
'https://gl.test/api/v1/webhooks/w-1/token-1',
);
await userEvent.click(within(item).getByRole('button', { name: 'Скопировать' }));
expect(writeText).toHaveBeenCalledWith('https://gl.test/api/v1/webhooks/w-1/token-1');
expect(await within(item).findByText('Скопировано')).toBeInTheDocument();
});
it('создаёт вебхук и подтверждает личность по запросу сервера', async () => {
const { fetchMock, content } = await openChannelSettings([]);
// Первый раз сервер требует step-up: секция показывает форму подтверждения.
fetchMock.mockImplementationOnce(() =>
Promise.resolve(json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403)),
);
fetchMock.mockImplementationOnce(() =>
Promise.resolve(json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403)),
);
await userEvent.click(within(content).getByTestId('webhook-create-open'));
const dialog = await screen.findByRole('dialog', { name: 'Создать вебхук' });
await userEvent.type(within(dialog).getByLabelText('Имя вебхука'), 'Новый');
await userEvent.click(within(dialog).getByRole('button', { name: 'Создать' }));
const stepUpField = await within(dialog).findByLabelText('Ваш пароль');
await userEvent.type(stepUpField, 'correct-horse-battery');
await userEvent.click(within(dialog).getByRole('button', { name: 'Подтвердить' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/auth/step-up', method: 'POST' })).toBeDefined();
});
await waitFor(() => {
const created = findRequest(fetchMock, { url: '/channels/c-1/webhooks', method: 'POST' });
expect(bodyOf(created)).toMatchObject({ name: 'Новый' });
});
});
it('пересоздаёт ссылку и удаляет вебхук', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const { fetchMock, content } = await openChannelSettings();
const item = within(content).getByTestId('webhook-w-1');
await userEvent.click(within(item).getByTestId('webhook-rotate-w-1'));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/webhooks/w-1', method: 'PATCH' })?.body).toMatchObject(
{ regenerate_token: true },
);
});
await userEvent.click(within(item).getByTestId('webhook-delete-w-1'));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/webhooks/w-1', method: 'DELETE' })).toBeDefined();
});
});
it('не показывает секцию без права MANAGE_WEBHOOKS', async () => {
const fetchMock = installFetch(routes({ list: [{ ...webhook(), id: 'w-2' }] }));
const snapshot = makeReadySnapshot([
{
id: 'g-1',
name: 'Сервер',
is_main: true,
// Права есть, но владелец — другой пользователь и админа нет.
owner_id: 'user-9',
my_permissions: [],
channels: [channel],
},
]);
const gateway = installGatewaySocket(snapshot);
renderApp('/app/g-1/c-1');
await gateway.greet();
await screen.findByTestId('message-list');
// Кнопки настроек комнаты нет вовсе: секция недоступна из интерфейса.
expect(screen.queryByTestId('channel-settings')).toBeNull();
expect(useSessionStore.getState().guilds[0]?.my_permissions).toEqual([]);
await waitFor(() => {
expect(fetchMock).toBeDefined();
});
});
it('открывает настройки комнаты из шапки чата', async () => {
await openChannelSettings();
expect(useUiStore.getState().settingsOpen).toBe(true);
expect(useUiStore.getState().settingsSection).toBe('channel');
expect(useSessionStore.getState().settingsChannelId).toBe('c-1');
});
});