feat(files): загрузка и выдача вложений

- POST /api/v1/channels/{id}/files (multipart, ATTACH_FILES): файл пишется на
  диск под идентификатором, в БД хранятся метаданные и sha256; имя файла
  очищается от путей и управляющих символов, объём ограничен MAX_UPLOAD_SIZE;
- GET/HEAD /files/{id}: содержимое с проверкой прав на комнату сообщения
  (невидимая комната и чужие вложения → 404), ETag, immutable-кэш,
  Content-Disposition с RFC 5987 для не-ASCII имён;
- вложения привязываются к сообщению при отправке (attachment_ids), сироты
  ищутся через store.ListOrphanFiles для обслуживания;
- GET /api/v1/files/{id} — метаданные файла для клиента;
- chi-ручки теперь умеют отдавать huma-ошибки в общем конверте
  (writeHumaAPIError), иначе 404 превращался в 500;
- READY отдаёт реальные read states пользователя.

Тесты: загрузка, скачивание участником, скрытая комната → 404, вложение в
сообщении, проверка подписи ETag.
This commit is contained in:
2026-09-19 23:36:16 +03:00
parent 63bf1e63b5
commit 0e39b8e027
25 changed files with 1162 additions and 39 deletions
+49 -6
View File
@@ -56,7 +56,7 @@ describe('настройки: безопасность', () => {
},
]);
renderApp('/settings/security');
renderApp('/settings/account/security');
const visitor = userEvent.setup();
await visitor.click(await screen.findByRole('button', { name: 'Включить 2FA' }));
@@ -85,7 +85,7 @@ describe('настройки: безопасность', () => {
]);
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
const { router } = renderApp('/settings/security');
const { router } = renderApp('/settings/account/security');
const visitor = userEvent.setup();
expect(await screen.findByTestId('sessions-list')).toHaveTextContent('Firefox on Linux');
@@ -121,7 +121,7 @@ describe('настройки: безопасность', () => {
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
]);
renderApp('/settings/security');
renderApp('/settings/account/security');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Текущий пароль'), 'old-password-1');
@@ -152,7 +152,7 @@ describe('настройки: безопасность', () => {
},
]);
renderApp('/settings/profile');
renderApp('/settings/account/profile');
const visitor = userEvent.setup();
const displayName = await screen.findByLabelText('Отображаемое имя');
@@ -168,12 +168,55 @@ describe('настройки: безопасность', () => {
it('вкладка «Инстанс» скрыта от не-администратора', async () => {
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
renderApp('/settings/profile');
renderApp('/settings/account/profile');
expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible();
expect(screen.queryByRole('link', { name: 'Инстанс' })).toBeNull();
});
it('вверху настроек — две соседние кнопки-переключателя', async () => {
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
]);
const { router } = renderApp('/settings/account/profile');
const account = await screen.findByRole('link', { name: 'Настройки сервера' });
const servers = screen.getByRole('link', { name: 'Мои серверы' });
expect(account).toHaveAttribute('aria-current', 'page');
expect(servers).not.toHaveAttribute('aria-current');
expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-0');
await userEvent.click(servers);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers');
});
expect(screen.getByRole('link', { name: 'Мои серверы' })).toHaveAttribute(
'aria-current',
'page',
);
// Подложка переключателя уезжает ко второй кнопке без перезагрузки.
expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-full');
});
it.each([
['/settings', '/settings/account/profile'],
['/settings/profile', '/settings/account/profile'],
['/settings/security', '/settings/account/security'],
['/settings/appearance', '/settings/account/appearance'],
['/settings/instance', '/settings/account/instance'],
])('старый путь %s редиректит на %s', async (from, to) => {
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
const { router } = renderApp(from);
await waitFor(() => {
expect(router.state.location.pathname).toBe(to);
});
});
it('администратор видит данные инстанса', async () => {
const admin = makeUser({ is_instance_admin: true });
installFetch([
@@ -220,7 +263,7 @@ describe('настройки: безопасность', () => {
},
]);
renderApp('/settings/instance');
renderApp('/settings/account/instance');
expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main');
expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice');