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