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:
2026-09-19 21:53:35 +03:00
parent c059cd9f51
commit bf0d130ee8
23 changed files with 1115 additions and 269 deletions
+191
View File
@@ -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();
});
});
+176
View File
@@ -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,
};
}
+100
View File
@@ -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',
});
});
});
+96
View File
@@ -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();
});
});
+110
View File
@@ -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();
});
});
+5
View File
@@ -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);
});