9c001fedfe
Клиент React 19 + TanStack Query + Zustand:
- вход (поле TOTP появляется на `auth.2fa_required`), регистрация с проверками
и учётом `registration_enabled`, онбординг первого входа с возможностью
пропустить, редирект `/` и страница `/status`;
- защита маршрутов: неавторизованных — на `/login`, без онбординга — на
`/onboarding`;
- оболочка `/app`: рейка серверов, сайдбар категорий и комнат, шапка сервера,
панель пользователя, модалки создания сервера и комнаты, экран «нет серверов»
со вступлением в главный сервер, заглушка комнаты до Фазы 2;
- настройки: профиль, безопасность (step-up, сессии, logout-all, 2FA с
локальным QR и кодами восстановления), внешний вид, админ-раздел инстанса;
- Gateway-клиент: HELLO/IDENTIFY/RESUME, heartbeat с ожиданием ACK,
экспоненциальное переподключение, разбор `INVALID_SESSION {reason,resumable}`;
- диспетчер событий: READY/RESUMED/USER_UPDATE/GUILD_UPDATE/GUILD_DELETE/
CHANNEL_* применяются к стору, GUILD_CREATE догружается по REST,
MEMBER_*/ROLE_* инвалидируют запросы участников, ролей и карточки сервера;
- i18n ru/en, 66 тестов Vitest, `check`/`lint`/`test`/`build` зелёные.
Серверные правки под клиент:
- `totp_enabled` в профиле (`GET /users/@me`, вход, регистрация) — клиенту
нужно знать, требовать ли код при step-up;
- `POST /auth/2fa/setup` отдаёт `otpauth_url` и `qr_png` (data-URI): QR-код
рисуется локально, внешние сервисы генерации QR не используются;
- права в ответах — имена (`VIEW_CHANNEL|SEND_MESSAGES`), клиент сверяет имена.
180 lines
6.5 KiB
TypeScript
180 lines
6.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { enableTotp, login, logoutAll, register, setupTotp, stepUp } from '@/api/auth';
|
|
import { createChannel, fetchChannels, joinGuild, leaveGuild } from '@/api/guilds';
|
|
import { fetchInstance, fetchAudit } from '@/api/instance';
|
|
import { fetchMyGuilds, updateProfile } from '@/api/users';
|
|
import { ApiError } from '@/api/client';
|
|
import { findRequest, installFetch, json, recordedRequests } from './helpers';
|
|
|
|
function lastCall(fetchMock: ReturnType<typeof installFetch>) {
|
|
const request = recordedRequests(fetchMock).at(-1);
|
|
if (request === undefined) {
|
|
throw new Error('fetch не вызывался');
|
|
}
|
|
return request;
|
|
}
|
|
|
|
describe('api-модули Фазы 1', () => {
|
|
it('register/login отправляют POST с телом и читают поле user', async () => {
|
|
const fetchMock = installFetch([
|
|
{
|
|
match: '/api/v1/auth/register',
|
|
method: 'POST',
|
|
response: () => json({ user: { id: 'u' } }),
|
|
},
|
|
]);
|
|
|
|
await expect(
|
|
register({
|
|
username: 'alice',
|
|
display_name: 'Alice',
|
|
email: 'a@b.co',
|
|
password: 'password12',
|
|
locale: 'ru',
|
|
}),
|
|
).resolves.toEqual({ user: { id: 'u' } });
|
|
expect(findRequest(fetchMock, { url: '/auth/register', method: 'POST' })?.method).toBe('POST');
|
|
});
|
|
|
|
it('login отправляет POST с телом', async () => {
|
|
const fetchMock = installFetch([
|
|
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user: { id: 'u' } }) },
|
|
]);
|
|
|
|
await login({ email: 'a@b.co', password: 'password12' });
|
|
|
|
expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({
|
|
email: 'a@b.co',
|
|
password: 'password12',
|
|
});
|
|
});
|
|
|
|
it('login прокидывает код 2FA как totp_code', async () => {
|
|
const fetchMock = installFetch([
|
|
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user: { id: 'u' } }) },
|
|
]);
|
|
|
|
await login({ email: 'a@b.co', password: 'password12', totp_code: '123456' });
|
|
|
|
expect(lastCall(fetchMock).body).toEqual({
|
|
email: 'a@b.co',
|
|
password: 'password12',
|
|
totp_code: '123456',
|
|
});
|
|
});
|
|
|
|
it('setup/enable 2FA и step-up используют нужные пути', async () => {
|
|
const fetchMock = installFetch([
|
|
{
|
|
match: '/api/v1/auth/2fa/setup',
|
|
method: 'POST',
|
|
response: () => json({ secret: 'SECRET', otpauth_url: 'otpauth://x' }),
|
|
},
|
|
{
|
|
match: '/api/v1/auth/2fa/enable',
|
|
method: 'POST',
|
|
response: () => json({ recovery_codes: ['one', 'two'] }),
|
|
},
|
|
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
|
|
{ match: '/api/v1/auth/logout-all', method: 'POST', response: () => json({ ok: true }) },
|
|
]);
|
|
|
|
await expect(setupTotp()).resolves.toEqual({ secret: 'SECRET', otpauth_url: 'otpauth://x' });
|
|
await expect(enableTotp('123456')).resolves.toEqual(['one', 'two']);
|
|
await stepUp('password12', '654321');
|
|
expect(lastCall(fetchMock).body).toEqual({
|
|
password: 'password12',
|
|
totp_code: '654321',
|
|
});
|
|
await expect(logoutAll()).resolves.toEqual({ ok: true });
|
|
});
|
|
|
|
it('PATCH профиля уходит на /users/@me и возвращает user', async () => {
|
|
const fetchMock = installFetch([
|
|
{
|
|
match: '/api/v1/users/@me',
|
|
method: 'PATCH',
|
|
response: () => json({ user: { id: 'u', display_name: 'Новое' } }),
|
|
},
|
|
]);
|
|
|
|
await expect(updateProfile({ display_name: 'Новое' })).resolves.toMatchObject({
|
|
display_name: 'Новое',
|
|
});
|
|
expect(lastCall(fetchMock).method).toBe('PATCH');
|
|
});
|
|
|
|
it('GET /users/@me/guilds разворачивает конверт {guilds}', async () => {
|
|
installFetch([
|
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [{ id: 'g-1' }] }) },
|
|
]);
|
|
|
|
await expect(fetchMyGuilds()).resolves.toEqual([{ id: 'g-1' }]);
|
|
});
|
|
|
|
it('каналы и участие в сервере используют id в пути', async () => {
|
|
const fetchMock = installFetch([
|
|
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [{ id: 'c-1' }] }) },
|
|
{ match: '/api/v1/guilds/g-1/join', method: 'POST', response: () => json({ ok: true }) },
|
|
{ match: '/api/v1/guilds/g-1/leave', method: 'POST', response: () => json({ ok: true }) },
|
|
]);
|
|
|
|
await expect(fetchChannels('g-1')).resolves.toEqual([{ id: 'c-1' }]);
|
|
await expect(joinGuild('g-1')).resolves.toEqual({ ok: true });
|
|
await expect(leaveGuild('g-1')).resolves.toEqual({ ok: true });
|
|
expect(lastCall(fetchMock).url).toContain('/api/v1/guilds/g-1/leave');
|
|
});
|
|
|
|
it('createChannel отправляет type и parent_id', async () => {
|
|
const fetchMock = installFetch([
|
|
{
|
|
match: '/api/v1/guilds/g-1/channels',
|
|
method: 'POST',
|
|
response: () => json({ channel: { id: 'c-9' } }),
|
|
},
|
|
]);
|
|
|
|
await createChannel('g-1', { name: 'общий', type: 'text', parent_id: 'cat-1' });
|
|
|
|
expect(lastCall(fetchMock).body).toEqual({
|
|
name: 'общий',
|
|
type: 'text',
|
|
parent_id: 'cat-1',
|
|
});
|
|
});
|
|
|
|
it('instance: конверты {instance}, {settings}, {entries} и лимит аудита', async () => {
|
|
const fetchMock = installFetch([
|
|
{ match: '/api/v1/instance/settings', response: () => json({ settings: { motd: 'x' } }) },
|
|
{ match: '/api/v1/instance/audit', response: () => json({ entries: [{ id: 'a-1' }] }) },
|
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat' } }) },
|
|
]);
|
|
|
|
await expect(fetchInstance()).resolves.toEqual({ name: 'glchat' });
|
|
await expect(fetchAudit(50)).resolves.toEqual([{ id: 'a-1' }]);
|
|
expect(recordedRequests(fetchMock).map((request) => request.url)).toContain(
|
|
'/api/v1/instance/audit?limit=50',
|
|
);
|
|
});
|
|
|
|
it('пробрасывает коды ошибок контракта (в том числе auth.guild_limit_reached)', async () => {
|
|
installFetch([
|
|
{
|
|
match: '/api/v1/guilds',
|
|
method: 'POST',
|
|
response: () =>
|
|
json({ error: { code: 'auth.guild_limit_reached', message: 'limit' } }, 403),
|
|
},
|
|
]);
|
|
|
|
const error = await import('@/api/guilds')
|
|
.then(({ createGuild }) => createGuild('Много'))
|
|
.catch((reason: unknown) => reason);
|
|
|
|
expect(error).toBeInstanceOf(ApiError);
|
|
expect((error as ApiError).code).toBe('auth.guild_limit_reached');
|
|
expect((error as ApiError).status).toBe(403);
|
|
});
|
|
});
|