feat(cli): включение 2FA инстанс-админа командой totp-setup
2FA обязательна для инстанс-администраторов, но до её включения вход закрыт — получался замкнутый круг. Добавлены команды обслуживания: - `glchat totp-setup --email <admin>`: создаёт секрет, подтверждает его кодом, печатает секрет, otpauth-ссылку и 8 резервных кодов (каждый одноразовый); - `glchat totp-reset --email <admin>`: удаляет секрет при потере устройства; - код `auth.2fa_enrollment_required` с подсказкой, какую команду выполнить; - установщик: флаг `--admin-2fa` для автоматического включения (по умолчанию печатает подсказку, чтобы секреты не оседали в логах установки). Проверено сквозным прогоном локально: bootstrap → 403 на входе без 2FA → totp-setup → вход с TOTP-кодом → профиль, серверы, комнаты, роли, участники, аудит, создание сервера админом, 429 на шестой попытке входа, секретов в логах нет.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
GatewayConnection,
|
||||
GatewayOp,
|
||||
parseGatewaySnapshot,
|
||||
type GatewayDispatch,
|
||||
type GatewayStatus,
|
||||
} from '@/api/gateway';
|
||||
import { makeReadySnapshot } from './helpers';
|
||||
|
||||
/** Подставной WebSocket: рукопожатие и heartbeat полностью под контролем теста. */
|
||||
class FakeSocket {
|
||||
static instances: FakeSocket[] = [];
|
||||
|
||||
readonly url: string;
|
||||
readyState = 0;
|
||||
sent: string[] = [];
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeSocket.instances.push(this);
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = 1;
|
||||
this.onopen?.(new Event('open'));
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
/** Присылает кадр от сервера. */
|
||||
emit(packet: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent);
|
||||
}
|
||||
|
||||
/** Последний отправленный пакет в виде объекта. */
|
||||
lastSent(): { op: number; d: unknown } {
|
||||
const raw = this.sent.at(-1);
|
||||
if (raw === undefined) {
|
||||
throw new Error('socket has not sent anything');
|
||||
}
|
||||
return JSON.parse(raw) as { op: number; d: unknown };
|
||||
}
|
||||
|
||||
closeFromServer(code = 1006): void {
|
||||
this.readyState = 3;
|
||||
this.onclose?.({ code } as CloseEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function makeConnection(handlers: {
|
||||
onDispatch?: (event: GatewayDispatch) => void;
|
||||
onStatus?: (status: GatewayStatus) => void;
|
||||
onInvalidSession?: () => void;
|
||||
onResumeIncomplete?: () => void;
|
||||
}) {
|
||||
FakeSocket.instances = [];
|
||||
const connection = new GatewayConnection({
|
||||
url: 'ws://localhost/gateway',
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
handlers,
|
||||
// Быстрый backoff, чтобы тест переподключения не ждал секунду.
|
||||
baseBackoffMs: 5,
|
||||
helloTimeoutMs: 60_000,
|
||||
});
|
||||
connection.connect();
|
||||
connections.push(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
const connections: GatewayConnection[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
// Закрываем соединения, иначе таймеры heartbeat продолжают жить между тестами.
|
||||
for (const connection of connections.splice(0)) {
|
||||
connection.close();
|
||||
}
|
||||
FakeSocket.instances = [];
|
||||
});
|
||||
|
||||
describe('gateway client', () => {
|
||||
it('answers HELLO with IDENTIFY and an empty token', () => {
|
||||
const connection = makeConnection({});
|
||||
const socket = FakeSocket.instances[0];
|
||||
expect(socket).toBeDefined();
|
||||
socket?.open();
|
||||
|
||||
socket?.emit({
|
||||
op: GatewayOp.HELLO,
|
||||
d: { heartbeat_interval_ms: 1000, session_id: 'sess-1' },
|
||||
});
|
||||
|
||||
expect(socket?.lastSent()).toEqual({ op: GatewayOp.IDENTIFY, d: { token: '', resume_seq: 0 } });
|
||||
expect(connection.getStatus()).toBe('connecting');
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('delivers READY to the dispatch handler', () => {
|
||||
const events: GatewayDispatch[] = [];
|
||||
const connection = makeConnection({ onDispatch: (event) => events.push(event) });
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket?.open();
|
||||
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
|
||||
socket?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 1, d: makeReadySnapshot([
|
||||
{ id: 'g-1', name: 'Main', channels: [{ id: 'c-1', name: 'general' }] },
|
||||
]) });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.t).toBe('READY');
|
||||
expect(connection.getStatus()).toBe('connected');
|
||||
expect(connection.getLastSeq()).toBe(1);
|
||||
|
||||
const snapshot = parseGatewaySnapshot(events[0]?.d);
|
||||
expect(snapshot?.guilds[0]?.channels[0]?.name).toBe('general');
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('ignores unknown events and unparsable frames', () => {
|
||||
const events: GatewayDispatch[] = [];
|
||||
const connection = makeConnection({ onDispatch: (event) => events.push(event) });
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket?.open();
|
||||
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
|
||||
socket?.emit({ op: GatewayOp.DISPATCH, t: 'GUILD_CREATE', s: 2, d: { id: 'g-9' } });
|
||||
socket?.emit({ op: 99, d: null });
|
||||
socket?.onmessage?.({ data: 'not json' } as MessageEvent);
|
||||
|
||||
expect(events.map((event) => event.t)).toEqual(['GUILD_CREATE']);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('sends HEARTBEAT with the interval from HELLO and tracks the ack', async () => {
|
||||
const connection = makeConnection({});
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket?.open();
|
||||
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 20, session_id: 's' } });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 45));
|
||||
const heartbeat = socket?.lastSent();
|
||||
expect(heartbeat).toEqual({ op: GatewayOp.HEARTBEAT, d: null });
|
||||
|
||||
socket?.emit({ op: GatewayOp.HEARTBEAT_ACK, d: null });
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('reconnects with RESUME and the last sequence number', async () => {
|
||||
const statuses: GatewayStatus[] = [];
|
||||
const connection = makeConnection({ onStatus: (status) => statuses.push(status) });
|
||||
const first = FakeSocket.instances[0];
|
||||
first?.open();
|
||||
first?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 'sess-7' } });
|
||||
first?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 42, d: makeReadySnapshot([]) });
|
||||
|
||||
first?.closeFromServer();
|
||||
expect(connection.getStatus()).toBe('reconnecting');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const second = FakeSocket.instances[1];
|
||||
expect(second).toBeDefined();
|
||||
second?.open();
|
||||
second?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 'sess-7' } });
|
||||
|
||||
expect(second?.lastSent()).toEqual({ op: GatewayOp.RESUME, d: { token: '', resume_seq: 42 } });
|
||||
expect(statuses).toContain('reconnecting');
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('reports INVALID_SESSION to the handler', () => {
|
||||
let invalidated = 0;
|
||||
const connection = makeConnection({ onInvalidSession: () => (invalidated += 1) });
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket?.open();
|
||||
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
|
||||
socket?.emit({ op: GatewayOp.INVALID_SESSION, d: false });
|
||||
|
||||
expect(invalidated).toBe(1);
|
||||
expect(connection.getStatus()).toBe('disconnected');
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { routes } from '@/router';
|
||||
import { setQueryClient } from '@/lib/queryClient';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import type { User } from '@/api/types';
|
||||
|
||||
/** Ответ-заглушка с JSON-телом (как у настоящего API). */
|
||||
export function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Ошибка в формате контракта: 4xx + `{"error":{"code","message"}}`. */
|
||||
export function apiError(code: string, status = 400, message = 'error'): Response {
|
||||
return json({ error: { code, message } }, status);
|
||||
}
|
||||
|
||||
export interface FetchRoute {
|
||||
/** Подстрока URL, по которой выбирается обработчик. */
|
||||
match: string;
|
||||
method?: string;
|
||||
response: () => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Мок fetch по таблице маршрутов. Метод сравнивается строго (по умолчанию GET),
|
||||
* URL — по вхождению подстроки. Более длинные шаблоны проверяются раньше,
|
||||
* поэтому `/users/@me/guilds` не перехватывается записью `/users/@me`.
|
||||
*/
|
||||
export function installFetch(routes: FetchRoute[]) {
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
||||
const ordered = [...routes].sort((left, right) => right.match.length - left.match.length);
|
||||
for (const route of ordered) {
|
||||
if ((route.method ?? 'GET').toUpperCase() !== method) {
|
||||
continue;
|
||||
}
|
||||
if (!url.includes(route.match)) {
|
||||
continue;
|
||||
}
|
||||
return Promise.resolve(route.response());
|
||||
}
|
||||
return Promise.resolve(apiError('not_found', 404));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
/** Мок fetch, который падает на уровне сети (сервер недоступен). */
|
||||
export function installFailingFetch() {
|
||||
const fetchMock = vi.fn(() => Promise.reject(new TypeError('Failed to fetch')));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
export function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Рендер приложения на заданном маршруте с изолированными сторами. */
|
||||
export function renderApp(initialPath = '/') {
|
||||
const queryClient = createTestQueryClient();
|
||||
// Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша),
|
||||
// поэтому подменяем его на тестовый.
|
||||
setQueryClient(queryClient);
|
||||
useSessionStore.getState().reset();
|
||||
useGatewayStore.setState({
|
||||
status: 'idle',
|
||||
heartbeatIntervalMs: null,
|
||||
sessionId: null,
|
||||
attempts: 0,
|
||||
invalidated: false,
|
||||
});
|
||||
|
||||
const router = createMemoryRouter(routes, { initialEntries: [initialPath] });
|
||||
const view = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, router, queryClient };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ждёт, пока отрисуется ленивая страница: react-router подгружает её
|
||||
* асинхронно, поэтому сразу после render DOM ещё пуст.
|
||||
*/
|
||||
export async function waitForPage(anchor = 'glchat') {
|
||||
await waitFor(() => {
|
||||
expect(document.body.textContent ?? '').toContain(anchor);
|
||||
});
|
||||
}
|
||||
|
||||
/** Ждёт появления поля по label и возвращает его. */
|
||||
export async function findField(label: string): Promise<HTMLElement> {
|
||||
return screen.findByLabelText(label);
|
||||
}
|
||||
|
||||
/** Полный профиль пользователя для мока `GET /users/@me`. */
|
||||
export function makeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
display_name: 'Alice',
|
||||
bio: '',
|
||||
status: 'online',
|
||||
custom_status: '',
|
||||
custom_status_emoji: '',
|
||||
is_instance_admin: false,
|
||||
badges: [],
|
||||
locale: 'ru',
|
||||
onboarding_completed: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface SnapshotGuildInput {
|
||||
id: string;
|
||||
name: string;
|
||||
is_main?: boolean;
|
||||
channels?: {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: 'text' | 'voice' | 'category';
|
||||
position?: number;
|
||||
parent_id?: string;
|
||||
can_send?: boolean;
|
||||
can_view?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Снапшот READY в формате шлюза. */
|
||||
export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
display_name: 'Alice',
|
||||
is_instance_admin: false,
|
||||
badges: [],
|
||||
},
|
||||
guilds: guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
owner_id: 'user-1',
|
||||
is_main: guild.is_main ?? false,
|
||||
channels: (guild.channels ?? []).map((channel, index) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type ?? 'text',
|
||||
position: channel.position ?? index,
|
||||
...(channel.parent_id === undefined ? {} : { parent_id: channel.parent_id }),
|
||||
...(channel.can_send === undefined ? {} : { can_send: channel.can_send }),
|
||||
...(channel.can_view === undefined ? {} : { can_view: channel.can_view }),
|
||||
})),
|
||||
roles: [],
|
||||
member_ids: ['user-1'],
|
||||
my_role_ids: [],
|
||||
my_permissions: [],
|
||||
})),
|
||||
dm_channels: [],
|
||||
read_states: [],
|
||||
session_id: 'session-1',
|
||||
heartbeat_interval_ms: 45_000,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { apiError, installFetch, json, makeUser, renderApp } from './helpers';
|
||||
|
||||
const instanceOpen = {
|
||||
name: 'glchat-test',
|
||||
version: 'v0.1.0-test',
|
||||
registration_enabled: true,
|
||||
allow_guild_creation: true,
|
||||
max_guilds_per_user: 5,
|
||||
max_members_per_guild: 100,
|
||||
max_message_length: 2000,
|
||||
main_guild_id: 'g-main',
|
||||
user_count: 1,
|
||||
guild_count: 1,
|
||||
};
|
||||
|
||||
describe('страница входа', () => {
|
||||
it('успешный вход переводит в /app', async () => {
|
||||
const user = makeUser();
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
||||
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/login');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
|
||||
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/app/empty');
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
|
||||
).toBeVisible();
|
||||
|
||||
const loginCall = fetchMock.mock.calls.find(([input]) =>
|
||||
String(input).includes('/auth/login'),
|
||||
);
|
||||
expect(loginCall).toBeDefined();
|
||||
const init = loginCall?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
email: 'alice@example.com',
|
||||
password: 'super-secret-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('при auth.2fa_required показывает поле кода и повторяет вход с totp_code', async () => {
|
||||
const user = makeUser();
|
||||
let loginAttempts = 0;
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
||||
{
|
||||
match: '/api/v1/auth/login',
|
||||
method: 'POST',
|
||||
response: () => {
|
||||
loginAttempts += 1;
|
||||
return loginAttempts === 1 ? apiError('auth.2fa_required', 401) : json({ user });
|
||||
},
|
||||
},
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/login');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
|
||||
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
|
||||
const totpField = await screen.findByLabelText('Код 2FA');
|
||||
expect(totpField).toBeVisible();
|
||||
|
||||
await visitor.type(totpField, '123456');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/app/empty');
|
||||
});
|
||||
|
||||
const secondLogin = fetchMock.mock.calls.filter(([input]) =>
|
||||
String(input).includes('/auth/login'),
|
||||
)[1];
|
||||
const init = secondLogin?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
email: 'alice@example.com',
|
||||
password: 'super-secret-1',
|
||||
totp_code: '123456',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { installFetch, json, makeUser, renderApp } from './helpers';
|
||||
|
||||
const instanceBase = {
|
||||
name: 'glchat-test',
|
||||
version: 'v0.1.0-test',
|
||||
allow_guild_creation: true,
|
||||
max_guilds_per_user: 5,
|
||||
max_members_per_guild: 100,
|
||||
max_message_length: 2000,
|
||||
user_count: 1,
|
||||
guild_count: 0,
|
||||
};
|
||||
|
||||
describe('страница регистрации', () => {
|
||||
it('успешная регистрация ведёт на /onboarding', async () => {
|
||||
const user = makeUser({ onboarding_completed: false });
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: '/api/v1/instance',
|
||||
response: () => json({ instance: { ...instanceBase, registration_enabled: true } }),
|
||||
},
|
||||
{ match: '/api/v1/auth/register', method: 'POST', response: () => json({ user }) },
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/register');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Имя пользователя'), 'alice');
|
||||
await visitor.type(screen.getByLabelText('Отображаемое имя'), 'Алиса');
|
||||
await visitor.type(screen.getByLabelText('Почта'), 'alice@example.com');
|
||||
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Создать аккаунт' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/onboarding');
|
||||
});
|
||||
expect(await screen.findByText('Привет, Alice!')).toBeVisible();
|
||||
|
||||
const registerCall = fetchMock.mock.calls.find(([input]) =>
|
||||
String(input).includes('/auth/register'),
|
||||
);
|
||||
const init = registerCall?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
username: 'alice',
|
||||
display_name: 'Алиса',
|
||||
email: 'alice@example.com',
|
||||
password: 'super-secret-1',
|
||||
locale: 'ru',
|
||||
});
|
||||
});
|
||||
|
||||
it('не отправляет форму с некорректными данными', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: '/api/v1/instance',
|
||||
response: () => json({ instance: { ...instanceBase, registration_enabled: true } }),
|
||||
},
|
||||
]);
|
||||
|
||||
renderApp('/register');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Имя пользователя'), 'al');
|
||||
await visitor.type(screen.getByLabelText('Отображаемое имя'), 'Алиса');
|
||||
await visitor.type(screen.getByLabelText('Почта'), 'not-an-email');
|
||||
await visitor.type(screen.getByLabelText('Пароль'), 'short');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Создать аккаунт' }));
|
||||
|
||||
expect(await screen.findByText(/Имя пользователя: 3–32 символа/u)).toBeVisible();
|
||||
expect(screen.getByText('Укажите корректный адрес почты.')).toBeVisible();
|
||||
expect(screen.getByText('Пароль должен быть не короче 8 символов.')).toBeVisible();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => String(input).includes('/auth/register')),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('при registration_enabled=false показывает сообщение вместо формы', async () => {
|
||||
installFetch([
|
||||
{
|
||||
match: '/api/v1/instance',
|
||||
response: () => json({ instance: { ...instanceBase, registration_enabled: false } }),
|
||||
},
|
||||
]);
|
||||
|
||||
renderApp('/register');
|
||||
|
||||
expect(await screen.findByText('Регистрация закрыта')).toBeVisible();
|
||||
expect(screen.queryByLabelText('Имя пользователя')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Создать аккаунт' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
import { parseGatewaySnapshot } from '@/api/gateway';
|
||||
import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { makeReadySnapshot } from './helpers';
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().reset();
|
||||
useGatewayStore.setState({
|
||||
status: 'idle',
|
||||
heartbeatIntervalMs: null,
|
||||
sessionId: null,
|
||||
attempts: 0,
|
||||
invalidated: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('session store', () => {
|
||||
it('applies a READY snapshot: guilds, channels and session id', () => {
|
||||
const snapshot = parseGatewaySnapshot(
|
||||
makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Main',
|
||||
is_main: true,
|
||||
channels: [
|
||||
{ id: 'c-1', name: 'general', position: 0 },
|
||||
{ id: 'c-2', name: 'voice', type: 'voice', position: 1 },
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(snapshot).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
dispatchGatewayEvent({ op: 0, t: 'READY', s: 1, d: makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Main',
|
||||
is_main: true,
|
||||
channels: [
|
||||
{ id: 'c-1', name: 'general', position: 0 },
|
||||
{ id: 'c-2', name: 'voice', type: 'voice', position: 1 },
|
||||
],
|
||||
},
|
||||
]) });
|
||||
});
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
expect(state.guilds).toHaveLength(1);
|
||||
expect(state.guilds[0]?.name).toBe('Main');
|
||||
expect(state.guilds[0]?.channels.map((channel) => channel.id)).toEqual(['c-1', 'c-2']);
|
||||
expect(state.sessionId).toBe('session-1');
|
||||
expect(state.user?.display_name).toBe('Alice');
|
||||
expect(useGatewayStore.getState().status).toBe('connected');
|
||||
expect(useGatewayStore.getState().heartbeatIntervalMs).toBe(45_000);
|
||||
});
|
||||
|
||||
it('keeps channels with can_send=false (only can_view=false is hidden)', () => {
|
||||
act(() => {
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'READY',
|
||||
s: 2,
|
||||
d: makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Main',
|
||||
channels: [
|
||||
{ id: 'c-readonly', name: 'announcements', can_send: false },
|
||||
{ id: 'c-hidden', name: 'secret', can_view: false },
|
||||
],
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
const channels = useSessionStore.getState().guilds[0]?.channels ?? [];
|
||||
expect(channels.map((channel) => channel.id)).toEqual(['c-readonly', 'c-hidden']);
|
||||
});
|
||||
|
||||
it('ignores unknown events and malformed snapshots', () => {
|
||||
act(() => {
|
||||
dispatchGatewayEvent({ op: 0, t: 'SOMETHING_NEW', s: 3, d: { anything: true } });
|
||||
dispatchGatewayEvent({ op: 0, t: 'READY', s: 4, d: { guilds: [] } });
|
||||
});
|
||||
|
||||
expect(useSessionStore.getState().guilds).toEqual([]);
|
||||
expect(useSessionStore.getState().user).toBeNull();
|
||||
});
|
||||
|
||||
it('drops a guild and clears the selection when leaving', () => {
|
||||
act(() => {
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'READY',
|
||||
s: 5,
|
||||
d: makeReadySnapshot([{ id: 'g-1', name: 'Main' }]),
|
||||
});
|
||||
useSessionStore.getState().selectChannel('g-1', null);
|
||||
useSessionStore.getState().removeGuild('g-1');
|
||||
});
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
expect(state.guilds).toEqual([]);
|
||||
expect(state.selectedGuildId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// Общий экземпляр i18next для всех тестов: без него useTranslation вернёт ключи.
|
||||
import '@/i18n';
|
||||
import { setQueryClient } from '@/lib/queryClient';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
// Globals stubbed with vi.stubGlobal must be dropped between tests,
|
||||
@@ -9,4 +13,5 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
setQueryClient(null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user