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