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,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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user