feat(web): базовый клиент Фазы 1 — вход, серверы, комнаты, настройки
Клиент React 19 + TanStack Query + Zustand:
- вход (поле TOTP появляется на `auth.2fa_required`), регистрация с проверками
и учётом `registration_enabled`, онбординг первого входа с возможностью
пропустить, редирект `/` и страница `/status`;
- защита маршрутов: неавторизованных — на `/login`, без онбординга — на
`/onboarding`;
- оболочка `/app`: рейка серверов, сайдбар категорий и комнат, шапка сервера,
панель пользователя, модалки создания сервера и комнаты, экран «нет серверов»
со вступлением в главный сервер, заглушка комнаты до Фазы 2;
- настройки: профиль, безопасность (step-up, сессии, logout-all, 2FA с
локальным QR и кодами восстановления), внешний вид, админ-раздел инстанса;
- Gateway-клиент: HELLO/IDENTIFY/RESUME, heartbeat с ожиданием ACK,
экспоненциальное переподключение, разбор `INVALID_SESSION {reason,resumable}`;
- диспетчер событий: READY/RESUMED/USER_UPDATE/GUILD_UPDATE/GUILD_DELETE/
CHANNEL_* применяются к стору, GUILD_CREATE догружается по REST,
MEMBER_*/ROLE_* инвалидируют запросы участников, ролей и карточки сервера;
- i18n ru/en, 66 тестов Vitest, `check`/`lint`/`test`/`build` зелёные.
Серверные правки под клиент:
- `totp_enabled` в профиле (`GET /users/@me`, вход, регистрация) — клиенту
нужно знать, требовать ли код при step-up;
- `POST /auth/2fa/setup` отдаёт `otpauth_url` и `qr_png` (data-URI): QR-код
рисуется локально, внешние сервисы генерации QR не используются;
- права в ответах — имена (`VIEW_CHANNEL|SEND_MESSAGES`), клиент сверяет имена.
This commit is contained in:
@@ -387,7 +387,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
|
||||
s.recordAudit(ctx, admin, 0, action, "user", &userID, "")
|
||||
s.dispatchUserUpdate(updated)
|
||||
output := &userOutput{}
|
||||
output.Body.User = profileFromUser(updated, false)
|
||||
output.Body.User = s.profileFromUser(ctx, updated, false)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,21 @@ type profilePayload struct {
|
||||
Locale string `json:"locale"`
|
||||
// OnboardingCompleted — признак пройденной первичной настройки (AGENT.md 7.2).
|
||||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||||
// TOTPEnabled — включена ли 2FA: клиенту нужно знать, требовать ли код
|
||||
// при step-up и показывать ли QR при настройке (AGENT.md 7.1).
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
}
|
||||
|
||||
// profileFromUser собирает профиль; состояние 2FA читается из сервиса
|
||||
// аутентификации (секрет в БД зашифрован, наружу отдаём только факт).
|
||||
func (s *Server) profileFromUser(ctx context.Context, user *store.User, includePrivate bool) profilePayload {
|
||||
payload := profileFromUser(user, includePrivate)
|
||||
if s.auth != nil {
|
||||
if enabled, err := s.auth.TOTPEnabled(ctx, user.ID); err == nil {
|
||||
payload.TOTPEnabled = enabled
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func profileFromUser(user *store.User, includePrivate bool) profilePayload {
|
||||
@@ -155,7 +170,7 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
||||
return nil, err
|
||||
}
|
||||
output := &meOutput{}
|
||||
output.Body.User = profileFromUser(user, true)
|
||||
output.Body.User = s.profileFromUser(ctx, user, true)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
@@ -193,7 +208,7 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
||||
// Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3).
|
||||
s.dispatchUserUpdate(updated)
|
||||
output := &meOutput{}
|
||||
output.Body.User = profileFromUser(updated, true)
|
||||
output.Body.User = s.profileFromUser(ctx, updated, true)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
@@ -248,7 +263,7 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
||||
}
|
||||
s.dispatchUserUpdate(updated)
|
||||
output := &meOutput{}
|
||||
output.Body.User = profileFromUser(updated, true)
|
||||
output.Body.User = s.profileFromUser(ctx, updated, true)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
@@ -308,7 +323,7 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
output := &userOutput{}
|
||||
output.Body.User = profileFromUser(user, false)
|
||||
output.Body.User = s.profileFromUser(ctx, user, false)
|
||||
return output, nil
|
||||
})
|
||||
|
||||
@@ -383,6 +398,6 @@ func (s *Server) dispatchUserUpdate(user *store.User) {
|
||||
return
|
||||
}
|
||||
s.gateway.SendToUser(user.ID, "USER_UPDATE", map[string]any{
|
||||
"user": profileFromUser(user, true),
|
||||
"user": s.profileFromUser(context.Background(), user, true),
|
||||
})
|
||||
}
|
||||
|
||||
+51
-7
@@ -1,10 +1,16 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pquerna/otp"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/gateway"
|
||||
@@ -84,6 +90,19 @@ type currentUserPayload struct {
|
||||
Locale string `json:"locale"`
|
||||
// OnboardingCompleted — пройдена ли первичная настройка (AGENT.md 7.2).
|
||||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||||
// TOTPEnabled — включена ли 2FA у пользователя (AGENT.md 7.1).
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
}
|
||||
|
||||
// withTOTPState дополняет профиль фактом включённой 2FA.
|
||||
func (s *Server) withTOTPState(ctx context.Context, user *store.User) map[string]any {
|
||||
payload := userPayload(user)
|
||||
if s.auth != nil {
|
||||
if enabled, err := s.auth.TOTPEnabled(ctx, user.ID); err == nil {
|
||||
payload.TOTPEnabled = enabled
|
||||
}
|
||||
}
|
||||
return map[string]any{"user": payload}
|
||||
}
|
||||
|
||||
func userPayload(user *store.User) currentUserPayload {
|
||||
@@ -134,7 +153,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
|
||||
writeJSON(w, map[string]any{"user": userPayload(user)})
|
||||
writeJSON(w, s.withTOTPState(r.Context(), user))
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -164,7 +183,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
|
||||
writeJSON(w, map[string]any{"user": userPayload(user)})
|
||||
writeJSON(w, s.withTOTPState(r.Context(), user))
|
||||
}
|
||||
|
||||
// authenticate читает сессию из cookie или Bearer-токена (desktop, AGENT.md 8.1).
|
||||
@@ -259,6 +278,23 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"sessions": payload})
|
||||
}
|
||||
|
||||
// totpQRDataURI рисует QR-код otpauth-ссылки локально и отдаёт data-URI.
|
||||
func totpQRDataURI(otpauthURL string) (string, error) {
|
||||
key, err := otp.NewKeyFromURL(otpauthURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
image, err := key.Image(256, 256)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
type totpEnableRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
@@ -273,11 +309,19 @@ func (s *Server) handleSetupTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"secret": setup.Secret,
|
||||
"url": setup.URL,
|
||||
"issuer": setup.IssuerName,
|
||||
})
|
||||
payload := map[string]any{
|
||||
"secret": setup.Secret,
|
||||
"otpauth_url": setup.URL,
|
||||
"issuer": setup.IssuerName,
|
||||
}
|
||||
// QR-код рисуем сами: внешние сервисы генерации QR запрещены (AGENT.md 7.1 —
|
||||
// никаких внешних зависимостей для секретов).
|
||||
if qr, err := totpQRDataURI(setup.URL); err == nil {
|
||||
payload["qr_png"] = qr
|
||||
} else {
|
||||
s.logger.WarnContext(r.Context(), "не удалось построить QR-код для 2FA", slog.Any("error", err))
|
||||
}
|
||||
writeJSON(w, payload)
|
||||
}
|
||||
|
||||
func (s *Server) handleEnableTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -242,10 +242,14 @@ const authSchemasJSON = `{
|
||||
},
|
||||
"TOTPSetup": {
|
||||
"type": "object",
|
||||
"required": ["secret", "url"],
|
||||
"required": ["secret", "otpauth_url"],
|
||||
"properties": {
|
||||
"secret": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"otpauth_url": { "type": "string" },
|
||||
"qr_png": {
|
||||
"type": "string",
|
||||
"description": "QR-код otpauth-ссылки как data:image/png;base64 (рисуется локально)"
|
||||
},
|
||||
"issuer": { "type": "string" }
|
||||
}
|
||||
},
|
||||
|
||||
+10
-2
@@ -54,8 +54,16 @@ export function stepUp(password: string, totpCode?: string): Promise<{ ok: true
|
||||
return request<{ ok: true }>('/auth/step-up', { method: 'POST', body });
|
||||
}
|
||||
|
||||
export function setupTotp(): Promise<{ secret: string; otpauth_url: string }> {
|
||||
return request<{ secret: string; otpauth_url: string }>('/auth/2fa/setup', { method: 'POST' });
|
||||
/** Ответ настройки 2FA: секрет, otpauth-ссылка и QR-код (data-URI с сервера). */
|
||||
export interface TotpSetup {
|
||||
secret: string;
|
||||
otpauth_url: string;
|
||||
/** QR рисует сервер: внешние сервисы генерации QR не используются. */
|
||||
qr_png?: string;
|
||||
}
|
||||
|
||||
export function setupTotp(): Promise<TotpSetup> {
|
||||
return request<TotpSetup>('/auth/2fa/setup', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function enableTotp(code: string): Promise<string[]> {
|
||||
|
||||
+17
-55
@@ -1,3 +1,4 @@
|
||||
import { parseChannelPayload } from './gatewayEvents';
|
||||
import { describeUnknown } from '@/lib/http';
|
||||
|
||||
import type { Channel, Role, UserStatus } from './types';
|
||||
@@ -20,9 +21,10 @@ export const GatewayOp = {
|
||||
IDENTIFY: 2,
|
||||
RESUME: 3,
|
||||
INVALID_SESSION: 4,
|
||||
/** RECONNECT по AGENT.md §8.3 — opcode 7. */
|
||||
RECONNECT: 7,
|
||||
HELLO: 10,
|
||||
HEARTBEAT_ACK: 11,
|
||||
RECONNECT: 12,
|
||||
} as const;
|
||||
|
||||
export const GATEWAY_CLOSE_INVALID_SESSION = 4000;
|
||||
@@ -142,54 +144,6 @@ function asStringArray(value: unknown): string[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
function parseChannel(value: unknown): Channel | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const name = asString(record['name']);
|
||||
const type = asString(record['type']);
|
||||
if (id === undefined || name === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (type !== 'text' && type !== 'voice' && type !== 'category') {
|
||||
return null;
|
||||
}
|
||||
const channel: Channel = {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position: asNumber(record['position']) ?? 0,
|
||||
};
|
||||
const guildId = asString(record['guild_id']);
|
||||
const parentId = asString(record['parent_id']);
|
||||
const userLimit = asNumber(record['user_limit']);
|
||||
const slowmode = asNumber(record['slowmode_seconds']);
|
||||
if (guildId !== undefined) {
|
||||
channel.guild_id = guildId;
|
||||
}
|
||||
if (parentId !== undefined) {
|
||||
channel.parent_id = parentId;
|
||||
}
|
||||
if (userLimit !== undefined) {
|
||||
channel.user_limit = userLimit;
|
||||
}
|
||||
if (slowmode !== undefined) {
|
||||
channel.slowmode_seconds = slowmode;
|
||||
}
|
||||
if (typeof record['can_send'] === 'boolean') {
|
||||
channel.can_send = record['can_send'];
|
||||
}
|
||||
if (typeof record['can_connect'] === 'boolean') {
|
||||
channel.can_connect = record['can_connect'];
|
||||
}
|
||||
if (typeof record['can_view'] === 'boolean') {
|
||||
channel.can_view = record['can_view'];
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
function parseRole(value: unknown): Role | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
@@ -265,7 +219,7 @@ function parseGuild(value: unknown): GatewayGuild | null {
|
||||
return null;
|
||||
}
|
||||
const channels = Array.isArray(record['channels'])
|
||||
? record['channels'].map(parseChannel).filter((item): item is Channel => item !== null)
|
||||
? record['channels'].map(parseChannelPayload).filter((item): item is Channel => item !== null)
|
||||
: [];
|
||||
const roles = Array.isArray(record['roles'])
|
||||
? record['roles'].map(parseRole).filter((item): item is Role => item !== null)
|
||||
@@ -306,7 +260,9 @@ export function parseGatewaySnapshot(value: unknown): GatewaySnapshot | null {
|
||||
? record['guilds'].map(parseGuild).filter((item): item is GatewayGuild => item !== null)
|
||||
: [];
|
||||
const dmChannels = Array.isArray(record['dm_channels'])
|
||||
? record['dm_channels'].map(parseChannel).filter((item): item is Channel => item !== null)
|
||||
? record['dm_channels']
|
||||
.map(parseChannelPayload)
|
||||
.filter((item): item is Channel => item !== null)
|
||||
: [];
|
||||
return {
|
||||
user,
|
||||
@@ -476,7 +432,8 @@ export class GatewayConnection {
|
||||
}
|
||||
this.attempt += 1;
|
||||
const backoff = Math.min(this.baseBackoffMs * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
|
||||
const jitter = Math.floor(Math.random() * 250);
|
||||
// Джиттер пропорционален задержке: при базовых 5 мс в тестах он не «съедает» ожидание.
|
||||
const jitter = Math.floor(Math.random() * Math.min(250, Math.max(1, backoff / 4)));
|
||||
this.setStatus('reconnecting');
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
@@ -554,10 +511,15 @@ export class GatewayConnection {
|
||||
return;
|
||||
}
|
||||
case GatewayOp.INVALID_SESSION: {
|
||||
const resumable = parsed.d === true;
|
||||
// Payload: { reason, resumable }. resumable=true (промах RESUME) —
|
||||
// сессия жива, сервер сразу пришлёт полный READY; разлогинивать нельзя.
|
||||
const data = asRecord(parsed.d);
|
||||
const resumable = data?.['resumable'] === true;
|
||||
if (resumable) {
|
||||
this.logger.warn('gateway: invalid session, retrying resume');
|
||||
this.dropSocket();
|
||||
this.logger.warn('gateway: invalid session, waiting for full READY');
|
||||
this.sessionId = null;
|
||||
this.lastSeq = 0;
|
||||
this.setStatus('connecting');
|
||||
return;
|
||||
}
|
||||
this.sessionId = null;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { userStatuses, type Channel, type UserStatus } from './types';
|
||||
import type { GatewayUser } from './gateway';
|
||||
|
||||
/**
|
||||
* Разбор payload'ов событий шлюза (AGENT.md §8.3).
|
||||
*
|
||||
* Все функции возвращают `null`, если кадр не соответствует контракту:
|
||||
* неизвестные и лишние поля не ломают диспетчер, а событие просто
|
||||
* игнорируется.
|
||||
*/
|
||||
|
||||
export interface UserUpdateEvent {
|
||||
user: GatewayUser;
|
||||
}
|
||||
|
||||
export interface GuildUpdateEvent {
|
||||
guild_id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface GuildDeleteEvent {
|
||||
guild_id: string;
|
||||
}
|
||||
|
||||
export interface GuildCreateEvent {
|
||||
guild_id: string;
|
||||
}
|
||||
|
||||
export interface ChannelDeleteEvent {
|
||||
channel_id: string;
|
||||
guild_id: string;
|
||||
}
|
||||
|
||||
export interface MemberUpdateEvent {
|
||||
guild_id: string;
|
||||
user_id: string;
|
||||
role_ids?: string[];
|
||||
role_id?: string;
|
||||
role_added?: boolean;
|
||||
}
|
||||
|
||||
export interface MemberRemoveEvent {
|
||||
guild_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface RoleEvent {
|
||||
guild_id: string;
|
||||
role_id: string;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] | undefined {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Пользователь в объёме `USER_UPDATE` (совместим со снапшотом READY). */
|
||||
export function parseGatewayUserPayload(value: unknown): GatewayUser | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const username = asString(record['username']);
|
||||
if (id === undefined || username === undefined) {
|
||||
return null;
|
||||
}
|
||||
const user: GatewayUser = {
|
||||
id,
|
||||
username,
|
||||
display_name: asString(record['display_name']) ?? username,
|
||||
is_instance_admin: record['is_instance_admin'] === true,
|
||||
badges: asStringArray(record['badges']) ?? [],
|
||||
};
|
||||
const avatar = asString(record['avatar_file_id']);
|
||||
const status = asString(record['status']);
|
||||
const customStatus = asString(record['custom_status']);
|
||||
const customEmoji = asString(record['custom_status_emoji']);
|
||||
if (avatar !== undefined) {
|
||||
user.avatar_file_id = avatar;
|
||||
}
|
||||
if (userStatuses.includes(status as UserStatus)) {
|
||||
user.status = status as UserStatus;
|
||||
}
|
||||
if (customStatus !== undefined) {
|
||||
user.custom_status = customStatus;
|
||||
}
|
||||
if (customEmoji !== undefined) {
|
||||
user.custom_status_emoji = customEmoji;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Деструктурирует `d` у событий, где payload вложен в `user`. */
|
||||
export function parseUserUpdate(value: unknown): UserUpdateEvent | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const user = parseGatewayUserPayload(record['user'] ?? value);
|
||||
return user === null ? null : { user };
|
||||
}
|
||||
|
||||
export function parseGuildUpdate(value: unknown): GuildUpdateEvent | null {
|
||||
const record = asRecord(value);
|
||||
const guildId = asString(record?.['guild_id']);
|
||||
if (record === null || guildId === undefined) {
|
||||
return null;
|
||||
}
|
||||
const event: GuildUpdateEvent = { guild_id: guildId };
|
||||
const name = asString(record['name']);
|
||||
const description = asString(record['description']);
|
||||
if (name !== undefined) {
|
||||
event.name = name;
|
||||
}
|
||||
if (description !== undefined) {
|
||||
event.description = description;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
export function parseGuildId(value: unknown): string | null {
|
||||
const record = asRecord(value);
|
||||
return asString(record?.['guild_id']) ?? null;
|
||||
}
|
||||
|
||||
export function parseChannelDelete(value: unknown): ChannelDeleteEvent | null {
|
||||
const record = asRecord(value);
|
||||
const channelId = asString(record?.['channel_id']);
|
||||
const guildId = asString(record?.['guild_id']);
|
||||
if (channelId === undefined || guildId === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { channel_id: channelId, guild_id: guildId };
|
||||
}
|
||||
|
||||
export function parseMemberUpdate(value: unknown): MemberUpdateEvent | null {
|
||||
const record = asRecord(value);
|
||||
const guildId = asString(record?.['guild_id']);
|
||||
const userId = asString(record?.['user_id']);
|
||||
if (record === null || guildId === undefined || userId === undefined) {
|
||||
return null;
|
||||
}
|
||||
const event: MemberUpdateEvent = { guild_id: guildId, user_id: userId };
|
||||
const roleIds = asStringArray(record['role_ids']);
|
||||
const roleId = asString(record['role_id']);
|
||||
if (roleIds !== undefined) {
|
||||
event.role_ids = roleIds;
|
||||
}
|
||||
if (roleId !== undefined) {
|
||||
event.role_id = roleId;
|
||||
}
|
||||
if (typeof record['role_added'] === 'boolean') {
|
||||
event.role_added = record['role_added'];
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
export function parseMemberRemove(value: unknown): MemberRemoveEvent | null {
|
||||
const event = parseMemberUpdate(value);
|
||||
return event === null ? null : { guild_id: event.guild_id, user_id: event.user_id };
|
||||
}
|
||||
|
||||
export function parseRoleEvent(value: unknown): RoleEvent | null {
|
||||
const record = asRecord(value);
|
||||
const guildId = asString(record?.['guild_id']);
|
||||
const roleId = asString(record?.['role_id']);
|
||||
if (record === null || guildId === undefined || roleId === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { guild_id: guildId, role_id: roleId };
|
||||
}
|
||||
|
||||
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
|
||||
export function parseChannelPayload(value: unknown): Channel | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
return null;
|
||||
}
|
||||
const id = asString(record['id']);
|
||||
const name = asString(record['name']);
|
||||
const type = asString(record['type']);
|
||||
if (id === undefined || name === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (type !== 'text' && type !== 'voice' && type !== 'category') {
|
||||
return null;
|
||||
}
|
||||
const channel: Channel = {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position: typeof record['position'] === 'number' ? record['position'] : 0,
|
||||
};
|
||||
const guildId = asString(record['guild_id']);
|
||||
const parentId = asString(record['parent_id']);
|
||||
if (guildId !== undefined) {
|
||||
channel.guild_id = guildId;
|
||||
}
|
||||
if (parentId !== undefined) {
|
||||
channel.parent_id = parentId;
|
||||
}
|
||||
if (typeof record['user_limit'] === 'number') {
|
||||
channel.user_limit = record['user_limit'];
|
||||
}
|
||||
if (typeof record['slowmode_seconds'] === 'number') {
|
||||
channel.slowmode_seconds = record['slowmode_seconds'];
|
||||
}
|
||||
if (typeof record['can_send'] === 'boolean') {
|
||||
channel.can_send = record['can_send'];
|
||||
}
|
||||
if (typeof record['can_connect'] === 'boolean') {
|
||||
channel.can_connect = record['can_connect'];
|
||||
}
|
||||
if (typeof record['can_view'] === 'boolean') {
|
||||
channel.can_view = record['can_view'];
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export interface User {
|
||||
badges: string[];
|
||||
locale: string;
|
||||
onboarding_completed: boolean;
|
||||
/** Включена ли 2FA: от неё зависит требование кода при step-up. */
|
||||
totp_enabled: boolean;
|
||||
}
|
||||
|
||||
/** Урезанное представление пользователя (участники сервера, READY). */
|
||||
@@ -52,7 +54,7 @@ export interface Role {
|
||||
name: string;
|
||||
color: number;
|
||||
position: number;
|
||||
/** Битовая маска прав (строка — чтобы не терять старшие биты в JSON). */
|
||||
/** Имена прав через `|`, как их отдаёт сервер (например `VIEW_CHANNEL|SEND_MESSAGES`). */
|
||||
permissions: string;
|
||||
is_default: boolean;
|
||||
hoist: boolean;
|
||||
|
||||
@@ -161,7 +161,8 @@
|
||||
"namePlaceholder": "For example, “Friends”",
|
||||
"hint": "You can rename it later in the server settings.",
|
||||
"submit": "Create",
|
||||
"limit": "You can create at most {{count}} servers on this instance."
|
||||
"limit": "You can create at most {{count}} servers on this instance.",
|
||||
"limitReached": "You have reached the server limit — remove one of your servers first."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No servers yet",
|
||||
|
||||
@@ -161,7 +161,8 @@
|
||||
"namePlaceholder": "Например, «Друзья»",
|
||||
"hint": "Название можно изменить позже в настройках сервера.",
|
||||
"submit": "Создать",
|
||||
"limit": "На инстансе можно создать не более {{count}} серверов."
|
||||
"limit": "На инстансе можно создать не более {{count}} серверов.",
|
||||
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих."
|
||||
},
|
||||
"empty": {
|
||||
"title": "Пока нет ни одного сервера",
|
||||
@@ -327,7 +328,8 @@
|
||||
"usersEmpty": "Пользователей нет.",
|
||||
"audit": "Журнал аудита (последние {{count}})",
|
||||
"auditEmpty": "Записей нет.",
|
||||
"memberCount": "{{count}} участн.", "adminBadge": "админ",
|
||||
"memberCount": "{{count}} участн.",
|
||||
"adminBadge": "админ",
|
||||
"mainBadge": "главный"
|
||||
}
|
||||
}
|
||||
|
||||
+10
-26
@@ -32,33 +32,17 @@ export function avatarUrl(fileId: string | undefined): string | null {
|
||||
return fileId === undefined || fileId === '' ? null : `/files/${encodeURIComponent(fileId)}`;
|
||||
}
|
||||
|
||||
/** Разбирает число прав из строковой битовой маски, не падая на мусоре. */
|
||||
export function parseBigInt(value: string | number | undefined): bigint {
|
||||
if (value === undefined) {
|
||||
return 0n;
|
||||
}
|
||||
try {
|
||||
return BigInt(value);
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Права приходят с сервера списком имён (`permissions.Names` в Go):
|
||||
* например `["VIEW_GUILD", "MANAGE_CHANNELS"]`. Проверяем вхождение имени.
|
||||
*/
|
||||
export const PERMISSION_ADMINISTRATOR = 'ADMINISTRATOR';
|
||||
export const PERMISSION_MANAGE_GUILD = 'MANAGE_GUILD';
|
||||
export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS';
|
||||
|
||||
/** Бит ADMINISTRATOR в маске прав (совпадает с серверной константой). */
|
||||
export const PERMISSION_ADMINISTRATOR = 1n << 3n;
|
||||
export const PERMISSION_MANAGE_GUILD = 1n << 5n;
|
||||
export const PERMISSION_MANAGE_CHANNELS = 1n << 6n;
|
||||
export const PERMISSION_OWNER = 1n << 62n;
|
||||
|
||||
/** Проверяет право в списке масок прав текущего пользователя. */
|
||||
export function hasPermission(permissions: readonly string[], mask: bigint): boolean {
|
||||
for (const value of permissions) {
|
||||
const parsed = parseBigInt(value);
|
||||
if (parsed === PERMISSION_OWNER || (parsed & mask) !== 0n) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
/** Есть ли у пользователя право с указанным именем. */
|
||||
export function hasPermission(permissions: readonly string[], name: string): boolean {
|
||||
return permissions.includes(name);
|
||||
}
|
||||
|
||||
/** Может ли пользователь управлять сервером (создавать комнаты и т.п.). */
|
||||
|
||||
@@ -6,24 +6,26 @@ import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { createGuild, fetchGuild, guildQueryKey } from '@/api/guilds';
|
||||
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { instanceQueryKey } from '@/api/instance';
|
||||
import { GatewayBridge } from '@/components/GatewayBridge';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field } from '@/components/ui/Field';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { useInstance } from '@/lib/hooks';
|
||||
import { useCurrentUser, useInstance } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
|
||||
import { GuildRail } from '@/pages/app/GuildRail';
|
||||
import { UserPanel } from '@/pages/app/UserPanel';
|
||||
import { errorCode } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* Оболочка приложения: рейка серверов, сайдбар комнат, контент и панель
|
||||
* пользователя. Данные серверов приходят из снапшота шлюза, а если шлюз ещё
|
||||
* не подключился — подстраховываемся REST-запросом `GET /users/@me/guilds`.
|
||||
* пользователя. Данные серверов приходят из снапшота шлюза (соединение
|
||||
* поднимает `GatewayBridge`), а пока шлюз не подключился — подстраховываемся
|
||||
* REST-запросом `GET /users/@me/guilds`.
|
||||
*/
|
||||
export default function AppLayout() {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -52,6 +54,21 @@ export default function AppLayout() {
|
||||
// `/app/empty` — служебный маршрут, а не идентификатор сервера.
|
||||
const realGuildId = guildId === 'empty' ? null : guildId;
|
||||
|
||||
// Сервер появился (вступили в главный или создали свой) — сразу открываем его.
|
||||
useEffect(() => {
|
||||
if (realGuildId !== null || guilds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const first = guilds[0];
|
||||
if (first === undefined) {
|
||||
return;
|
||||
}
|
||||
const channel = useSessionStore.getState().firstChannelId(first.id);
|
||||
void navigate(channel === null ? `/app/${first.id}` : `/app/${first.id}/${channel}`, {
|
||||
replace: true,
|
||||
});
|
||||
}, [realGuildId, guilds, navigate]);
|
||||
|
||||
const myGuilds = useQuery({
|
||||
queryKey: myGuildsQueryKey,
|
||||
queryFn: ({ signal }) => fetchMyGuilds(signal),
|
||||
@@ -129,6 +146,7 @@ export default function AppLayout() {
|
||||
>
|
||||
{t('app.skipToContent')}
|
||||
</a>
|
||||
<GatewayBridge userId={currentUser.data?.id ?? null} />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<GuildRail
|
||||
guilds={guildList}
|
||||
@@ -185,8 +203,10 @@ export default function AppLayout() {
|
||||
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
|
||||
</p>
|
||||
)}
|
||||
{limitReached && errorCode(create.error) === null ? (
|
||||
<p className="text-xs text-warning">{t('errors.auth.guild_limit_reached')}</p>
|
||||
{limitReached && !create.isError ? (
|
||||
<p className="text-xs text-warning" role="status">
|
||||
{t('guilds.create.limitReached')}
|
||||
</p>
|
||||
) : null}
|
||||
{create.isError ? <ErrorNotice error={create.error} /> : null}
|
||||
</form>
|
||||
|
||||
@@ -140,6 +140,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
||||
<NavLink
|
||||
to={`/app/${guildId}/${channel.id}`}
|
||||
aria-current={channel.id === channelId ? 'page' : undefined}
|
||||
aria-label={t('channels.select', { name: channel.name })}
|
||||
title={channel.name}
|
||||
className={`flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm ${
|
||||
channel.id === channelId
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { currentUserQueryKey, fetchCurrentUser } from '@/api/users';
|
||||
import { PasswordSection } from '@/pages/settings/PasswordSection';
|
||||
import { SessionsSection } from '@/pages/settings/SessionsSection';
|
||||
import { TwoFactorSection } from '@/pages/settings/TwoFactorSection';
|
||||
@@ -9,13 +10,16 @@ import { TwoFactorSection } from '@/pages/settings/TwoFactorSection';
|
||||
* Безопасность: смена пароля со step-up, список сессий с выходом на всех
|
||||
* устройствах и включение 2FA с показом секрета и кодов восстановления.
|
||||
*
|
||||
* Контракт Фазы 1 не отдаёт флаг включённой 2FA в `GET /users/@me`, поэтому
|
||||
* состояние «2FA включена» ведём локально: после успешного `2fa/enable`
|
||||
* формы запрашивают код подтверждения.
|
||||
* Состояние 2FA берём из профиля (`totp_enabled`), обновляя запрос после
|
||||
* включения: от него зависит, требовать ли код при step-up.
|
||||
*/
|
||||
export default function SecuritySettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||
const currentUser = useQuery({
|
||||
queryKey: currentUserQueryKey,
|
||||
queryFn: ({ signal }) => fetchCurrentUser(signal),
|
||||
});
|
||||
const twoFactorEnabled = currentUser.data?.totp_enabled === true;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
@@ -25,7 +29,12 @@ export default function SecuritySettingsPage() {
|
||||
</header>
|
||||
<PasswordSection twoFactorEnabled={twoFactorEnabled} />
|
||||
<SessionsSection />
|
||||
<TwoFactorSection enabled={twoFactorEnabled} onEnabled={() => setTwoFactorEnabled(true)} />
|
||||
<TwoFactorSection
|
||||
enabled={twoFactorEnabled}
|
||||
onEnabled={() => {
|
||||
void currentUser.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export function TwoFactorSection({
|
||||
const { t } = useTranslation();
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [otpauthUrl, setOtpauthUrl] = useState('');
|
||||
const [qrPng, setQrPng] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
const [qrFailed, setQrFailed] = useState(false);
|
||||
@@ -31,6 +32,7 @@ export function TwoFactorSection({
|
||||
onSuccess: (payload) => {
|
||||
setSecret(payload.secret);
|
||||
setOtpauthUrl(payload.otpauth_url);
|
||||
setQrPng(payload.qr_png ?? '');
|
||||
setRecoveryCodes(null);
|
||||
setQrFailed(false);
|
||||
},
|
||||
@@ -81,15 +83,13 @@ export function TwoFactorSection({
|
||||
<p className="text-sm text-fg-muted">{t('settings.security.twoFactorSetupHint')}</p>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
{qrFailed || otpauthUrl === '' ? (
|
||||
{qrFailed || qrPng === '' ? (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('settings.security.twoFactorQrUnavailable')}
|
||||
</p>
|
||||
) : (
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(
|
||||
otpauthUrl,
|
||||
)}`}
|
||||
src={qrPng}
|
||||
alt={t('settings.security.twoFactorQrAlt')}
|
||||
width={160}
|
||||
height={160}
|
||||
|
||||
+148
-6
@@ -8,6 +8,14 @@ import {
|
||||
type GatewayStatus,
|
||||
type GatewayUser,
|
||||
} from '@/api/gateway';
|
||||
import {
|
||||
parseChannelDelete,
|
||||
parseChannelPayload,
|
||||
parseGuildId,
|
||||
parseGuildUpdate,
|
||||
parseRoleEvent,
|
||||
parseUserUpdate,
|
||||
} from '@/api/gatewayEvents';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
@@ -103,15 +111,81 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
useGatewayStore.setState({ status: 'connected', attempts: 0 });
|
||||
return;
|
||||
}
|
||||
// Заготовка диспетчера Фазы 2: события приходят, но пока не меняют UI.
|
||||
case 'GUILD_CREATE':
|
||||
case 'GUILD_UPDATE':
|
||||
case 'GUILD_DELETE':
|
||||
case 'USER_UPDATE': {
|
||||
// Профиль текущего пользователя обновился (например, с другого устройства).
|
||||
const update = parseUserUpdate(event.d);
|
||||
if (update !== null) {
|
||||
session.applyUserUpdate(update.user);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'GUILD_CREATE': {
|
||||
// В событии только guild_id — перечитываем список серверов по REST.
|
||||
const guildId = parseGuildId(event.d);
|
||||
if (guildId !== null) {
|
||||
void addGuildFromRest(guildId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'GUILD_UPDATE': {
|
||||
const update = parseGuildUpdate(event.d);
|
||||
if (update?.name !== undefined) {
|
||||
session.renameGuild(update.guild_id, update.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'GUILD_DELETE': {
|
||||
const guildId = parseGuildId(event.d);
|
||||
if (guildId === null) {
|
||||
return;
|
||||
}
|
||||
// removeGuild сам выбирает первый оставшийся сервер (или пустой список).
|
||||
session.removeGuild(guildId);
|
||||
void invalidateGuildQueries(guildId);
|
||||
syncUrlWithSelection();
|
||||
return;
|
||||
}
|
||||
case 'CHANNEL_CREATE':
|
||||
case 'CHANNEL_UPDATE':
|
||||
case 'CHANNEL_DELETE':
|
||||
case 'CHANNEL_UPDATE': {
|
||||
const channel = parseChannelPayload(event.d);
|
||||
if (channel === null) {
|
||||
return;
|
||||
}
|
||||
const guildId = channel.guild_id ?? session.selectedGuildId;
|
||||
if (guildId !== null) {
|
||||
session.upsertChannel(guildId, channel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'CHANNEL_DELETE': {
|
||||
const deleted = parseChannelDelete(event.d);
|
||||
if (deleted === null) {
|
||||
return;
|
||||
}
|
||||
session.removeChannel(deleted.guild_id, deleted.channel_id);
|
||||
syncUrlWithSelection();
|
||||
return;
|
||||
}
|
||||
case 'MEMBER_UPDATE':
|
||||
case 'MEMBER_REMOVE': {
|
||||
// Роли участника влияют на права: перечитываем участников и карточку сервера.
|
||||
const guildId = parseGuildId(event.d);
|
||||
if (guildId !== null) {
|
||||
void invalidateMembershipQueries(guildId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'ROLE_CREATE':
|
||||
case 'ROLE_UPDATE':
|
||||
case 'ROLE_DELETE': {
|
||||
const role = parseRoleEvent(event.d);
|
||||
if (role !== null) {
|
||||
void invalidateMembershipQueries(role.guild_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'PRESENCE_UPDATE':
|
||||
// Присутствие других участников в Фазе 1 не отображается.
|
||||
return;
|
||||
default:
|
||||
// Неизвестные события игнорируем: контракт может расширяться.
|
||||
@@ -119,6 +193,74 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Сервер появился (создан или найден): забираем сводку из REST. */
|
||||
async function addGuildFromRest(guildId: string): Promise<void> {
|
||||
const [{ getQueryClient }, users] = await Promise.all([
|
||||
import('@/lib/queryClient'),
|
||||
import('@/api/users'),
|
||||
]);
|
||||
const client = getQueryClient();
|
||||
try {
|
||||
const guilds = await client.fetchQuery({
|
||||
queryKey: users.myGuildsQueryKey,
|
||||
queryFn: () => users.fetchMyGuilds(),
|
||||
});
|
||||
const guild = guilds.find((item) => item.id === guildId);
|
||||
if (guild !== undefined) {
|
||||
useSessionStore.getState().upsertGuild(guild);
|
||||
}
|
||||
} catch {
|
||||
// Сеть подвела — просто помечаем данные устаревшими для следующего рендера.
|
||||
await client.invalidateQueries({ queryKey: users.myGuildsQueryKey });
|
||||
}
|
||||
}
|
||||
|
||||
/** Инвалидация REST-данных сервера и участников после событий ролей. */
|
||||
async function invalidateMembershipQueries(guildId: string): Promise<void> {
|
||||
const [{ getQueryClient }, guilds] = await Promise.all([
|
||||
import('@/lib/queryClient'),
|
||||
import('@/api/guilds'),
|
||||
]);
|
||||
const client = getQueryClient();
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: guilds.guildMembersQueryKey(guildId) }),
|
||||
client.invalidateQueries({ queryKey: guilds.guildRolesQueryKey(guildId) }),
|
||||
client.invalidateQueries({ queryKey: guilds.guildQueryKey(guildId) }),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Инвалидация данных удалённого сервера (карточка, участники, каналы). */
|
||||
async function invalidateGuildQueries(guildId: string): Promise<void> {
|
||||
const [{ getQueryClient }, guilds] = await Promise.all([
|
||||
import('@/lib/queryClient'),
|
||||
import('@/api/guilds'),
|
||||
]);
|
||||
const client = getQueryClient();
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: guilds.guildQueryKey(guildId) }),
|
||||
client.invalidateQueries({ queryKey: guilds.guildChannelsQueryKey(guildId) }),
|
||||
client.invalidateQueries({ queryKey: guilds.guildMembersQueryKey(guildId) }),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Держит URL в согласии с выбранным сервером/комнатой после удалений. */
|
||||
function syncUrlWithSelection(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const state = useSessionStore.getState();
|
||||
const target =
|
||||
state.selectedGuildId === null
|
||||
? '/app/empty'
|
||||
: state.selectedChannelId === null
|
||||
? `/app/${state.selectedGuildId}`
|
||||
: `/app/${state.selectedGuildId}/${state.selectedChannelId}`;
|
||||
if (window.location.pathname !== target) {
|
||||
window.history.replaceState(null, '', target);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
}
|
||||
|
||||
function parseResumedUser(payload: unknown): GatewayUser | null {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return null;
|
||||
|
||||
@@ -29,10 +29,18 @@ interface SessionState {
|
||||
setUser: (user: GatewayUser | null) => void;
|
||||
/** Синхронизация с REST-профилем (PATCH /users/@me возвращает полного user). */
|
||||
patchUser: (user: User) => void;
|
||||
/** USER_UPDATE: обновляет только текущего пользователя. */
|
||||
applyUserUpdate: (user: GatewayUser) => void;
|
||||
upsertGuild: (guild: GuildSummary) => void;
|
||||
/** GUILD_UPDATE: имя сервера (описание в снапшоте Фазы 1 не хранится). */
|
||||
renameGuild: (guildId: string, name: string) => void;
|
||||
removeGuild: (guildId: string) => void;
|
||||
setGuildChannels: (guildId: string, channels: Channel[]) => void;
|
||||
upsertChannel: (guildId: string, channel: Channel) => void;
|
||||
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
|
||||
removeChannel: (guildId: string, channelId: string) => void;
|
||||
/** Первая доступная (видимая) комната сервера — для выбора после удалений. */
|
||||
firstChannelId: (guildId: string) => string | null;
|
||||
selectGuild: (guildId: string | null) => void;
|
||||
selectChannel: (guildId: string | null, channelId: string | null) => void;
|
||||
reset: () => void;
|
||||
@@ -137,6 +145,15 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
set({ user: next });
|
||||
},
|
||||
|
||||
applyUserUpdate: (user) => {
|
||||
const current = get().user;
|
||||
if (current === null || current.id !== user.id) {
|
||||
// Профиль другого участника в Фазе 1 негде показывать.
|
||||
return;
|
||||
}
|
||||
set({ user });
|
||||
},
|
||||
|
||||
upsertGuild: (guild) => {
|
||||
const guilds = get().guilds;
|
||||
const index = guilds.findIndex((item) => item.id === guild.id);
|
||||
@@ -154,12 +171,26 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
set({ guilds: next });
|
||||
},
|
||||
|
||||
renameGuild: (guildId, name) => {
|
||||
set({
|
||||
guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, name } : guild)),
|
||||
});
|
||||
},
|
||||
|
||||
removeGuild: (guildId) => {
|
||||
const state = get();
|
||||
const guilds = state.guilds.filter((guild) => guild.id !== guildId);
|
||||
const selectionLost = state.selectedGuildId === guildId;
|
||||
// Если удалили выбранный сервер — выбираем первый оставшийся.
|
||||
const nextGuild = selectionLost ? (guilds[0] ?? null) : null;
|
||||
set({
|
||||
guilds: state.guilds.filter((guild) => guild.id !== guildId),
|
||||
selectedGuildId: state.selectedGuildId === guildId ? null : state.selectedGuildId,
|
||||
selectedChannelId: state.selectedGuildId === guildId ? null : state.selectedChannelId,
|
||||
guilds,
|
||||
selectedGuildId: selectionLost ? (nextGuild?.id ?? null) : state.selectedGuildId,
|
||||
selectedChannelId: selectionLost
|
||||
? nextGuild === null
|
||||
? null
|
||||
: firstVisibleChannelId(nextGuild)
|
||||
: state.selectedChannelId,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -186,6 +217,24 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
removeChannel: (guildId, channelId) => {
|
||||
const state = get();
|
||||
const guild = state.guilds.find((item) => item.id === guildId);
|
||||
const channels = (guild?.channels ?? []).filter((channel) => channel.id !== channelId);
|
||||
const selectionLost = state.selectedChannelId === channelId;
|
||||
set({
|
||||
guilds: state.guilds.map((item) => (item.id === guildId ? { ...item, channels } : item)),
|
||||
selectedChannelId: selectionLost
|
||||
? firstVisibleChannelId({ channels })
|
||||
: state.selectedChannelId,
|
||||
});
|
||||
},
|
||||
|
||||
firstChannelId: (guildId) => {
|
||||
const guild = get().guilds.find((item) => item.id === guildId);
|
||||
return guild === undefined ? null : firstVisibleChannelId(guild);
|
||||
},
|
||||
|
||||
selectGuild: (guildId) => {
|
||||
set({ selectedGuildId: guildId, selectedChannelId: null });
|
||||
},
|
||||
@@ -199,6 +248,16 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
/** Первая видимая текстовая (или любая) комната сервера. */
|
||||
function firstVisibleChannelId(guild: { channels: Channel[] }): string | null {
|
||||
const visible = guild.channels
|
||||
.filter((channel) => channel.type !== 'category' && channel.can_view !== false)
|
||||
.slice()
|
||||
.sort((left, right) => left.position - right.position);
|
||||
const text = visible.find((channel) => channel.type === 'text') ?? visible[0];
|
||||
return text?.id ?? null;
|
||||
}
|
||||
|
||||
/** Селектор сервера по id (без подписки на весь список). */
|
||||
export function selectGuildById(guildId: string | null) {
|
||||
return (state: SessionState): SessionGuild | null =>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { enableTotp, login, logoutAll, register, setupTotp, stepUp } from '@/api/auth';
|
||||
import { createChannel, fetchChannels, joinGuild, leaveGuild } from '@/api/guilds';
|
||||
import { fetchInstance, fetchAudit } from '@/api/instance';
|
||||
import { fetchMyGuilds, updateProfile } from '@/api/users';
|
||||
import { ApiError } from '@/api/client';
|
||||
import { findRequest, installFetch, json, recordedRequests } from './helpers';
|
||||
|
||||
function lastCall(fetchMock: ReturnType<typeof installFetch>) {
|
||||
const request = recordedRequests(fetchMock).at(-1);
|
||||
if (request === undefined) {
|
||||
throw new Error('fetch не вызывался');
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
describe('api-модули Фазы 1', () => {
|
||||
it('register/login отправляют POST с телом и читают поле user', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: '/api/v1/auth/register',
|
||||
method: 'POST',
|
||||
response: () => json({ user: { id: 'u' } }),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
register({
|
||||
username: 'alice',
|
||||
display_name: 'Alice',
|
||||
email: 'a@b.co',
|
||||
password: 'password12',
|
||||
locale: 'ru',
|
||||
}),
|
||||
).resolves.toEqual({ user: { id: 'u' } });
|
||||
expect(findRequest(fetchMock, { url: '/auth/register', method: 'POST' })?.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('login отправляет POST с телом', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user: { id: 'u' } }) },
|
||||
]);
|
||||
|
||||
await login({ email: 'a@b.co', password: 'password12' });
|
||||
|
||||
expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({
|
||||
email: 'a@b.co',
|
||||
password: 'password12',
|
||||
});
|
||||
});
|
||||
|
||||
it('login прокидывает код 2FA как totp_code', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user: { id: 'u' } }) },
|
||||
]);
|
||||
|
||||
await login({ email: 'a@b.co', password: 'password12', totp_code: '123456' });
|
||||
|
||||
expect(lastCall(fetchMock).body).toEqual({
|
||||
email: 'a@b.co',
|
||||
password: 'password12',
|
||||
totp_code: '123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('setup/enable 2FA и step-up используют нужные пути', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: '/api/v1/auth/2fa/setup',
|
||||
method: 'POST',
|
||||
response: () => json({ secret: 'SECRET', otpauth_url: 'otpauth://x' }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/auth/2fa/enable',
|
||||
method: 'POST',
|
||||
response: () => json({ recovery_codes: ['one', 'two'] }),
|
||||
},
|
||||
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
|
||||
{ match: '/api/v1/auth/logout-all', method: 'POST', response: () => json({ ok: true }) },
|
||||
]);
|
||||
|
||||
await expect(setupTotp()).resolves.toEqual({ secret: 'SECRET', otpauth_url: 'otpauth://x' });
|
||||
await expect(enableTotp('123456')).resolves.toEqual(['one', 'two']);
|
||||
await stepUp('password12', '654321');
|
||||
expect(lastCall(fetchMock).body).toEqual({
|
||||
password: 'password12',
|
||||
totp_code: '654321',
|
||||
});
|
||||
await expect(logoutAll()).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('PATCH профиля уходит на /users/@me и возвращает user', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: '/api/v1/users/@me',
|
||||
method: 'PATCH',
|
||||
response: () => json({ user: { id: 'u', display_name: 'Новое' } }),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(updateProfile({ display_name: 'Новое' })).resolves.toMatchObject({
|
||||
display_name: 'Новое',
|
||||
});
|
||||
expect(lastCall(fetchMock).method).toBe('PATCH');
|
||||
});
|
||||
|
||||
it('GET /users/@me/guilds разворачивает конверт {guilds}', async () => {
|
||||
installFetch([
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [{ id: 'g-1' }] }) },
|
||||
]);
|
||||
|
||||
await expect(fetchMyGuilds()).resolves.toEqual([{ id: 'g-1' }]);
|
||||
});
|
||||
|
||||
it('каналы и участие в сервере используют id в пути', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [{ id: 'c-1' }] }) },
|
||||
{ match: '/api/v1/guilds/g-1/join', method: 'POST', response: () => json({ ok: true }) },
|
||||
{ match: '/api/v1/guilds/g-1/leave', method: 'POST', response: () => json({ ok: true }) },
|
||||
]);
|
||||
|
||||
await expect(fetchChannels('g-1')).resolves.toEqual([{ id: 'c-1' }]);
|
||||
await expect(joinGuild('g-1')).resolves.toEqual({ ok: true });
|
||||
await expect(leaveGuild('g-1')).resolves.toEqual({ ok: true });
|
||||
expect(lastCall(fetchMock).url).toContain('/api/v1/guilds/g-1/leave');
|
||||
});
|
||||
|
||||
it('createChannel отправляет type и parent_id', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: '/api/v1/guilds/g-1/channels',
|
||||
method: 'POST',
|
||||
response: () => json({ channel: { id: 'c-9' } }),
|
||||
},
|
||||
]);
|
||||
|
||||
await createChannel('g-1', { name: 'общий', type: 'text', parent_id: 'cat-1' });
|
||||
|
||||
expect(lastCall(fetchMock).body).toEqual({
|
||||
name: 'общий',
|
||||
type: 'text',
|
||||
parent_id: 'cat-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('instance: конверты {instance}, {settings}, {entries} и лимит аудита', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/instance/settings', response: () => json({ settings: { motd: 'x' } }) },
|
||||
{ match: '/api/v1/instance/audit', response: () => json({ entries: [{ id: 'a-1' }] }) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat' } }) },
|
||||
]);
|
||||
|
||||
await expect(fetchInstance()).resolves.toEqual({ name: 'glchat' });
|
||||
await expect(fetchAudit(50)).resolves.toEqual([{ id: 'a-1' }]);
|
||||
expect(recordedRequests(fetchMock).map((request) => request.url)).toContain(
|
||||
'/api/v1/instance/audit?limit=50',
|
||||
);
|
||||
});
|
||||
|
||||
it('пробрасывает коды ошибок контракта (в том числе auth.guild_limit_reached)', async () => {
|
||||
installFetch([
|
||||
{
|
||||
match: '/api/v1/guilds',
|
||||
method: 'POST',
|
||||
response: () =>
|
||||
json({ error: { code: 'auth.guild_limit_reached', message: 'limit' } }, 403),
|
||||
},
|
||||
]);
|
||||
|
||||
const error = await import('@/api/guilds')
|
||||
.then(({ createGuild }) => createGuild('Много'))
|
||||
.catch((reason: unknown) => reason);
|
||||
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect((error as ApiError).code).toBe('auth.guild_limit_reached');
|
||||
expect((error as ApiError).status).toBe(403);
|
||||
});
|
||||
});
|
||||
+24
-43
@@ -1,10 +1,8 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { App } from '@/App';
|
||||
import { installFetch, installFailingFetch, json, renderApp } from './helpers';
|
||||
import type { InstanceMeta } from '@/api/meta';
|
||||
import '@/i18n';
|
||||
|
||||
const meta: InstanceMeta = {
|
||||
name: 'glchat',
|
||||
@@ -22,42 +20,22 @@ const meta: InstanceMeta = {
|
||||
anti_bot_enabled: false,
|
||||
voice_enabled: false,
|
||||
web_push_enabled: false,
|
||||
oauth_enabled: false,
|
||||
passkeys_enabled: false,
|
||||
oauth_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const readyBody = { status: 'ready', version: 'test', checks: { database: 'ok' } };
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
/** Страница состояния — бывший лендинг Фазы 0, перенесён на маршрут /status. */
|
||||
describe('страница /status', () => {
|
||||
it('рендерит мету инстанса из API', async () => {
|
||||
installFetch([
|
||||
{ match: '/api/v1/meta', response: () => json(meta) },
|
||||
{ match: '/api/v1/readyz', response: () => json(readyBody) },
|
||||
]);
|
||||
|
||||
function renderApp() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
it('renders instance metadata from the API', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
return Promise.resolve(url.endsWith('/api/v1/meta') ? json(meta) : json(readyBody));
|
||||
}),
|
||||
);
|
||||
|
||||
renderApp();
|
||||
renderApp('/status');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('v0.1.0-test')).toBeInTheDocument();
|
||||
@@ -67,13 +45,16 @@ describe('App', () => {
|
||||
expect(screen.getByTestId('connection-state')).toHaveTextContent('Сервер доступен');
|
||||
});
|
||||
|
||||
it('localizes a server error by its code and offers a retry', async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
json({ error: { code: 'internal.error', message: 'boom' } }, 500),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
it('локализует ошибку сервера по коду и предлагает повтор', async () => {
|
||||
installFetch([
|
||||
{
|
||||
match: '/api/v1/meta',
|
||||
response: () => json({ error: { code: 'internal.error', message: 'boom' } }, 500),
|
||||
},
|
||||
{ match: '/api/v1/readyz', response: () => json(readyBody) },
|
||||
]);
|
||||
|
||||
renderApp();
|
||||
renderApp('/status');
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
@@ -84,10 +65,10 @@ describe('App', () => {
|
||||
expect(screen.getByRole('button', { name: 'Повторить' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to a network message when the server is unreachable', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
|
||||
it('показывает сообщение о недоступном сервере', async () => {
|
||||
installFailingFetch();
|
||||
|
||||
renderApp();
|
||||
renderApp('/status');
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
|
||||
+63
-16
@@ -59,6 +59,16 @@ class FakeSocket {
|
||||
}
|
||||
}
|
||||
|
||||
const connections: GatewayConnection[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
// Закрываем соединения, иначе таймеры heartbeat продолжают жить между тестами.
|
||||
for (const connection of connections.splice(0)) {
|
||||
connection.close();
|
||||
}
|
||||
FakeSocket.instances = [];
|
||||
});
|
||||
|
||||
function makeConnection(handlers: {
|
||||
onDispatch?: (event: GatewayDispatch) => void;
|
||||
onStatus?: (status: GatewayStatus) => void;
|
||||
@@ -79,16 +89,6 @@ function makeConnection(handlers: {
|
||||
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({});
|
||||
@@ -112,9 +112,14 @@ describe('gateway client', () => {
|
||||
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' }] },
|
||||
]) });
|
||||
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');
|
||||
@@ -165,7 +170,7 @@ describe('gateway client', () => {
|
||||
first?.closeFromServer();
|
||||
expect(connection.getStatus()).toBe('reconnecting');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const second = FakeSocket.instances[1];
|
||||
expect(second).toBeDefined();
|
||||
second?.open();
|
||||
@@ -176,16 +181,58 @@ describe('gateway client', () => {
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('reports INVALID_SESSION to the handler', () => {
|
||||
it('INVALID_SESSION с resumable=false разлогинивает', () => {
|
||||
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 });
|
||||
socket?.emit({
|
||||
op: GatewayOp.INVALID_SESSION,
|
||||
d: { reason: 'invalid token', resumable: false },
|
||||
});
|
||||
|
||||
expect(invalidated).toBe(1);
|
||||
expect(connection.getStatus()).toBe('disconnected');
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('INVALID_SESSION с resumable=true не разлогинивает и ждёт полный READY', () => {
|
||||
const events: GatewayDispatch[] = [];
|
||||
let invalidated = 0;
|
||||
const connection = makeConnection({
|
||||
onDispatch: (event) => events.push(event),
|
||||
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.DISPATCH, t: 'READY', s: 3, d: makeReadySnapshot([]) });
|
||||
socket?.emit({
|
||||
op: GatewayOp.INVALID_SESSION,
|
||||
d: { reason: 'resume missed', resumable: true },
|
||||
});
|
||||
socket?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 4, d: makeReadySnapshot([]) });
|
||||
|
||||
expect(invalidated).toBe(0);
|
||||
expect(events.map((event) => event.t)).toEqual(['READY', 'READY']);
|
||||
expect(connection.getLastSeq()).toBe(4);
|
||||
expect(connection.getStatus()).toBe('connected');
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it('op 7 (RECONNECT) переподключает сокет', async () => {
|
||||
const connection = makeConnection({});
|
||||
const first = FakeSocket.instances[0];
|
||||
first?.open();
|
||||
first?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
|
||||
first?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 5, d: makeReadySnapshot([]) });
|
||||
|
||||
first?.emit({ op: GatewayOp.RECONNECT, d: null });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(FakeSocket.instances.length).toBe(2);
|
||||
expect(connection.getStatus()).toBe('reconnecting');
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { GatewayOp, parseGatewaySnapshot } from '@/api/gateway';
|
||||
import { guildMembersQueryKey, guildQueryKey, guildRolesQueryKey } from '@/api/guilds';
|
||||
import { setQueryClient } from '@/lib/queryClient';
|
||||
import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { installFetch, json, makeReadySnapshot, recordedRequests } from './helpers';
|
||||
|
||||
/** Наполняет стор снапшотом READY с двумя серверами и комнатами. */
|
||||
function seedSession() {
|
||||
const snapshot = parseGatewaySnapshot(
|
||||
makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Первый',
|
||||
channels: [
|
||||
{ id: 'c-1', name: 'общий', position: 0 },
|
||||
{ id: 'c-2', name: 'флудилка', position: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'g-2',
|
||||
name: 'Второй',
|
||||
channels: [{ id: 'c-9', name: 'болталка', position: 0 }],
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (snapshot === null) {
|
||||
throw new Error('снапшот не разобрался');
|
||||
}
|
||||
useSessionStore.getState().applyReady(snapshot);
|
||||
}
|
||||
|
||||
function makeRecordingClient(): { client: QueryClient; invalidated: string[][] } {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const invalidated: string[][] = [];
|
||||
const original = client.invalidateQueries.bind(client);
|
||||
client.invalidateQueries = (filters?: { queryKey?: readonly unknown[] }) => {
|
||||
invalidated.push((filters?.queryKey ?? []).map((part) => String(part)));
|
||||
return original(filters);
|
||||
};
|
||||
setQueryClient(client);
|
||||
return { client, invalidated };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().reset();
|
||||
useGatewayStore.setState({
|
||||
status: 'idle',
|
||||
heartbeatIntervalMs: null,
|
||||
sessionId: null,
|
||||
attempts: 0,
|
||||
invalidated: false,
|
||||
});
|
||||
setQueryClient(null);
|
||||
});
|
||||
|
||||
describe('диспетчер событий шлюза', () => {
|
||||
it('USER_UPDATE обновляет профиль текущего пользователя', () => {
|
||||
seedSession();
|
||||
expect(useSessionStore.getState().user?.display_name).toBe('Alice');
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'USER_UPDATE',
|
||||
s: 2,
|
||||
d: {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
display_name: 'Алиса',
|
||||
is_instance_admin: false,
|
||||
badges: ['early'],
|
||||
avatar_file_id: 'file-7',
|
||||
custom_status: 'Пишу код',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = useSessionStore.getState().user;
|
||||
expect(user?.display_name).toBe('Алиса');
|
||||
expect(user?.avatar_file_id).toBe('file-7');
|
||||
expect(user?.custom_status).toBe('Пишу код');
|
||||
expect(user?.badges).toEqual(['early']);
|
||||
});
|
||||
|
||||
it('USER_UPDATE про другого пользователя ничего не меняет', () => {
|
||||
seedSession();
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'USER_UPDATE',
|
||||
s: 3,
|
||||
d: { user: { id: 'user-2', username: 'bob', display_name: 'Bob' } },
|
||||
});
|
||||
|
||||
expect(useSessionStore.getState().user?.display_name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('GUILD_UPDATE меняет имя сервера', () => {
|
||||
seedSession();
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'GUILD_UPDATE',
|
||||
s: 4,
|
||||
d: { guild_id: 'g-2', name: 'Второй (обновлён)' },
|
||||
});
|
||||
|
||||
expect(useSessionStore.getState().guilds.find((g) => g.id === 'g-2')?.name).toBe(
|
||||
'Второй (обновлён)',
|
||||
);
|
||||
});
|
||||
|
||||
it('GUILD_DELETE удаляет сервер и выбирает первый оставшийся с комнатой', () => {
|
||||
seedSession();
|
||||
useSessionStore.getState().selectChannel('g-2', 'c-9');
|
||||
|
||||
dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 5, d: { guild_id: 'g-2' } });
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
expect(state.guilds.map((guild) => guild.id)).toEqual(['g-1']);
|
||||
expect(state.selectedGuildId).toBe('g-1');
|
||||
expect(state.selectedChannelId).toBe('c-1');
|
||||
});
|
||||
|
||||
it('GUILD_DELETE последнего сервера очищает выбор', () => {
|
||||
seedSession();
|
||||
useSessionStore.getState().selectChannel('g-2', 'c-9');
|
||||
|
||||
dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 6, d: { guild_id: 'g-1' } });
|
||||
dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 7, d: { guild_id: 'g-2' } });
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
expect(state.guilds).toEqual([]);
|
||||
expect(state.selectedGuildId).toBeNull();
|
||||
expect(state.selectedChannelId).toBeNull();
|
||||
});
|
||||
|
||||
it('CHANNEL_CREATE добавляет комнату, CHANNEL_UPDATE заменяет её', () => {
|
||||
seedSession();
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'CHANNEL_CREATE',
|
||||
s: 8,
|
||||
d: {
|
||||
id: 'c-3',
|
||||
guild_id: 'g-1',
|
||||
name: 'анонсы',
|
||||
type: 'text',
|
||||
position: 2,
|
||||
can_send: false,
|
||||
},
|
||||
});
|
||||
|
||||
const created = useSessionStore
|
||||
.getState()
|
||||
.guilds.find((guild) => guild.id === 'g-1')
|
||||
?.channels.find((channel) => channel.id === 'c-3');
|
||||
expect(created?.name).toBe('анонсы');
|
||||
expect(created?.can_send).toBe(false);
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'CHANNEL_UPDATE',
|
||||
s: 9,
|
||||
d: { id: 'c-3', guild_id: 'g-1', name: 'новости', type: 'text', position: 2 },
|
||||
});
|
||||
|
||||
const updated = useSessionStore
|
||||
.getState()
|
||||
.guilds.find((guild) => guild.id === 'g-1')
|
||||
?.channels.filter((channel) => channel.id === 'c-3');
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(updated?.[0]?.name).toBe('новости');
|
||||
});
|
||||
|
||||
it('CHANNEL_DELETE удаляет комнату и переносит выбор на первую доступную', () => {
|
||||
seedSession();
|
||||
useSessionStore.getState().selectChannel('g-1', 'c-1');
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'CHANNEL_DELETE',
|
||||
s: 10,
|
||||
d: { channel_id: 'c-1', guild_id: 'g-1' },
|
||||
});
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
expect(
|
||||
state.guilds.find((guild) => guild.id === 'g-1')?.channels.map((channel) => channel.id),
|
||||
).toEqual(['c-2']);
|
||||
expect(state.selectedChannelId).toBe('c-2');
|
||||
});
|
||||
|
||||
it('MEMBER_UPDATE с ролями инвалидирует участников, роли и карточку сервера', async () => {
|
||||
const { client, invalidated } = makeRecordingClient();
|
||||
client.setQueryData(guildMembersQueryKey('g-1'), []);
|
||||
client.setQueryData(guildRolesQueryKey('g-1'), []);
|
||||
client.setQueryData(guildQueryKey('g-1'), {});
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'MEMBER_UPDATE',
|
||||
s: 11,
|
||||
d: {
|
||||
guild_id: 'g-1',
|
||||
user_id: 'user-2',
|
||||
role_ids: ['role-3'],
|
||||
role_id: 'role-3',
|
||||
role_added: true,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invalidated.flat()).toContain('members');
|
||||
});
|
||||
expect(client.getQueryState(guildMembersQueryKey('g-1'))?.isInvalidated).toBe(true);
|
||||
expect(client.getQueryState(guildRolesQueryKey('g-1'))?.isInvalidated).toBe(true);
|
||||
expect(client.getQueryState(guildQueryKey('g-1'))?.isInvalidated).toBe(true);
|
||||
expect(invalidated.map((key) => key.join('/'))).toEqual(
|
||||
expect.arrayContaining(['guilds/g-1/members', 'guilds/g-1/roles', 'guilds/g-1']),
|
||||
);
|
||||
});
|
||||
|
||||
it('ROLE_DELETE инвалидирует данные сервера', async () => {
|
||||
const { client, invalidated } = makeRecordingClient();
|
||||
client.setQueryData(guildMembersQueryKey('g-1'), []);
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'ROLE_DELETE',
|
||||
s: 12,
|
||||
d: { guild_id: 'g-1', role_id: 'role-3' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.getQueryState(guildMembersQueryKey('g-1'))?.isInvalidated).toBe(true);
|
||||
});
|
||||
expect(invalidated.map((key) => key.join('/'))).toContain('guilds/g-1/roles');
|
||||
});
|
||||
|
||||
it('MEMBER_REMOVE инвалидирует список участников', async () => {
|
||||
const { client } = makeRecordingClient();
|
||||
client.setQueryData(guildMembersQueryKey('g-1'), []);
|
||||
|
||||
dispatchGatewayEvent({
|
||||
op: 0,
|
||||
t: 'MEMBER_REMOVE',
|
||||
s: 13,
|
||||
d: { guild_id: 'g-1', user_id: 'user-2' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.getQueryState(guildMembersQueryKey('g-1'))?.isInvalidated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('GUILD_CREATE подтягивает сервер из REST и кладёт его в стор', async () => {
|
||||
const { client } = makeRecordingClient();
|
||||
const guild = {
|
||||
id: 'g-3',
|
||||
name: 'Новый',
|
||||
owner_id: 'user-1',
|
||||
is_main: false,
|
||||
member_count: 1,
|
||||
my_role_ids: [],
|
||||
my_permissions: [],
|
||||
};
|
||||
// В событии только guild_id — клиент обязан сходить за сводкой в REST.
|
||||
const fetchMock = installFetch([
|
||||
{
|
||||
match: '/api/v1/users/@me/guilds',
|
||||
response: () => json({ guilds: [guild] }),
|
||||
},
|
||||
]);
|
||||
void client;
|
||||
|
||||
dispatchGatewayEvent({ op: 0, t: 'GUILD_CREATE', s: 14, d: { guild_id: 'g-3' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useSessionStore.getState().guilds.some((item) => item.id === 'g-3')).toBe(true);
|
||||
});
|
||||
expect(useSessionStore.getState().guilds.find((item) => item.id === 'g-3')?.name).toBe('Новый');
|
||||
expect(
|
||||
recordedRequests(fetchMock).some((request) => request.url.includes('/users/@me/guilds')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('неизвестные и повреждённые события не ломают диспетчер', () => {
|
||||
seedSession();
|
||||
|
||||
dispatchGatewayEvent({ op: 0, t: 'SOMETHING_NEW', s: 15, d: { x: 1 } });
|
||||
dispatchGatewayEvent({ op: 0, t: 'CHANNEL_CREATE', s: 16, d: { name: 'без id' } });
|
||||
dispatchGatewayEvent({ op: 0, t: 'GUILD_DELETE', s: 17, d: null });
|
||||
dispatchGatewayEvent({ op: 0, t: 'USER_UPDATE', s: 18, d: 'мусор' });
|
||||
|
||||
expect(useSessionStore.getState().guilds).toHaveLength(2);
|
||||
expect(useSessionStore.getState().user?.display_name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('оп-коды соответствуют AGENT.md §8.3', () => {
|
||||
expect(GatewayOp.INVALID_SESSION).toBe(4);
|
||||
expect(GatewayOp.RECONNECT).toBe(7);
|
||||
expect(GatewayOp.HELLO).toBe(10);
|
||||
expect(GatewayOp.HEARTBEAT_ACK).toBe(11);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { apiError, installFetch, installFailingFetch, json, makeUser, renderApp } from './helpers';
|
||||
|
||||
describe('защита маршрутов', () => {
|
||||
it('неавторизованного на /app отправляет на /login', async () => {
|
||||
installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => apiError('auth.unauthorized', 401) },
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/app');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
expect(await screen.findByLabelText('Почта')).toBeVisible();
|
||||
});
|
||||
|
||||
it('пользователя с onboarding_completed=false отправляет на /onboarding', async () => {
|
||||
const user = makeUser({ onboarding_completed: false });
|
||||
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
|
||||
|
||||
const { router } = renderApp('/app');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/onboarding');
|
||||
});
|
||||
expect(await screen.findByText('Привет, Alice!')).toBeVisible();
|
||||
});
|
||||
|
||||
it('онбординг доступен при незавершённой настройке (без цикла редиректов)', async () => {
|
||||
const user = makeUser({ onboarding_completed: false });
|
||||
installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{
|
||||
match: '/api/v1/users/@me/onboarding/complete',
|
||||
method: 'POST',
|
||||
response: () => json({ user }),
|
||||
},
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/onboarding');
|
||||
|
||||
expect(await screen.findByLabelText('Как вас показывать?')).toBeVisible();
|
||||
expect(router.state.location.pathname).toBe('/onboarding');
|
||||
});
|
||||
|
||||
it('/ без сессии ведёт на /login, а /status доступен без авторизации', async () => {
|
||||
installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => apiError('auth.unauthorized', 401) },
|
||||
{ match: '/api/v1/meta', response: () => apiError('internal.error', 500) },
|
||||
{ match: '/api/v1/readyz', response: () => apiError('internal.error', 500) },
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/');
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
const status = renderApp('/status');
|
||||
expect(await status.findByTestId('connection-state')).toHaveTextContent('Сервер недоступен');
|
||||
});
|
||||
|
||||
it('сетевую ошибку показывает как сообщение, а не редирект', async () => {
|
||||
installFailingFetch();
|
||||
|
||||
const { router } = renderApp('/app');
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Сервер не отвечает');
|
||||
expect(router.state.location.pathname).toBe('/app');
|
||||
});
|
||||
});
|
||||
+45
-1
@@ -37,7 +37,9 @@ export interface FetchRoute {
|
||||
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 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) {
|
||||
@@ -67,6 +69,47 @@ export function createTestQueryClient(): QueryClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** URL запроса независимо от того, строка это или Request. */
|
||||
export function requestUrl(input: RequestInfo | URL): string {
|
||||
return input instanceof Request ? input.url : String(input);
|
||||
}
|
||||
|
||||
export interface RecordedRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/** Последний (или отфильтрованный) вызов fetch в удобном виде. */
|
||||
export function recordedRequests(fetchMock: ReturnType<typeof installFetch>): RecordedRequest[] {
|
||||
return fetchMock.mock.calls.map(([input, init]) => {
|
||||
const request = input instanceof Request ? input : undefined;
|
||||
const raw = init?.body;
|
||||
return {
|
||||
url: requestUrl(input),
|
||||
method: (init?.method ?? request?.method ?? 'GET').toUpperCase(),
|
||||
body: typeof raw === 'string' ? (JSON.parse(raw) as unknown) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Тело запроса как JSON-объект (или undefined, если тела не было). */
|
||||
export function bodyOf(request: RecordedRequest | undefined): unknown {
|
||||
return request?.body;
|
||||
}
|
||||
|
||||
/** Первый записанный запрос, подходящий под фильтр. */
|
||||
export function findRequest(
|
||||
fetchMock: ReturnType<typeof installFetch>,
|
||||
predicate: { url: string; method?: string },
|
||||
): RecordedRequest | undefined {
|
||||
return recordedRequests(fetchMock).find(
|
||||
(request) =>
|
||||
request.url.includes(predicate.url) &&
|
||||
(predicate.method === undefined || request.method === predicate.method.toUpperCase()),
|
||||
);
|
||||
}
|
||||
|
||||
/** Рендер приложения на заданном маршруте с изолированными сторами. */
|
||||
export function renderApp(initialPath = '/') {
|
||||
const queryClient = createTestQueryClient();
|
||||
@@ -120,6 +163,7 @@ export function makeUser(overrides: Partial<User> = {}): User {
|
||||
badges: [],
|
||||
locale: 'ru',
|
||||
onboarding_completed: true,
|
||||
totp_enabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
+15
-12
@@ -2,7 +2,15 @@ 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';
|
||||
import {
|
||||
apiError,
|
||||
findRequest,
|
||||
installFetch,
|
||||
json,
|
||||
makeUser,
|
||||
recordedRequests,
|
||||
renderApp,
|
||||
} from './helpers';
|
||||
|
||||
const instanceOpen = {
|
||||
name: 'glchat-test',
|
||||
@@ -42,12 +50,7 @@ describe('страница входа', () => {
|
||||
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({
|
||||
expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({
|
||||
email: 'alice@example.com',
|
||||
password: 'super-secret-1',
|
||||
});
|
||||
@@ -87,11 +90,11 @@ describe('страница входа', () => {
|
||||
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({
|
||||
const logins = recordedRequests(fetchMock).filter(
|
||||
(request) => request.url.includes('/auth/login') && request.method === 'POST',
|
||||
);
|
||||
expect(logins).toHaveLength(2);
|
||||
expect(logins[1]?.body).toEqual({
|
||||
email: 'alice@example.com',
|
||||
password: 'super-secret-1',
|
||||
totp_code: '123456',
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { findRequest, installFetch, json, makeUser, recordedRequests, renderApp } from './helpers';
|
||||
|
||||
const instanceBase = {
|
||||
name: 'glchat-test',
|
||||
@@ -41,11 +41,7 @@ describe('страница регистрации', () => {
|
||||
});
|
||||
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({
|
||||
expect(findRequest(fetchMock, { url: '/auth/register', method: 'POST' })?.body).toEqual({
|
||||
username: 'alice',
|
||||
display_name: 'Алиса',
|
||||
email: 'alice@example.com',
|
||||
@@ -75,7 +71,7 @@ describe('страница регистрации', () => {
|
||||
expect(screen.getByText('Укажите корректный адрес почты.')).toBeVisible();
|
||||
expect(screen.getByText('Пароль должен быть не короче 8 символов.')).toBeVisible();
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => String(input).includes('/auth/register')),
|
||||
recordedRequests(fetchMock).filter((request) => request.url.includes('/auth/register')),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
||||
+16
-11
@@ -35,17 +35,22 @@ describe('session store', () => {
|
||||
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 },
|
||||
],
|
||||
},
|
||||
]) });
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import {
|
||||
apiError,
|
||||
findRequest,
|
||||
installFetch,
|
||||
json,
|
||||
makeUser,
|
||||
recordedRequests,
|
||||
renderApp,
|
||||
} from './helpers';
|
||||
|
||||
const user = makeUser();
|
||||
|
||||
const sessions = [
|
||||
{
|
||||
id: 's-1',
|
||||
user_agent: 'Firefox on Linux',
|
||||
ip: '10.0.0.2',
|
||||
created_at: '2026-09-01T10:00:00Z',
|
||||
last_seen: '2026-09-19T10:00:00Z',
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
id: 's-2',
|
||||
user_agent: 'Chrome on macOS',
|
||||
ip: '10.0.0.3',
|
||||
created_at: '2026-09-02T10:00:00Z',
|
||||
last_seen: '2026-09-18T10:00:00Z',
|
||||
current: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe('настройки: безопасность', () => {
|
||||
it('включение 2FA показывает секрет и коды восстановления', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
|
||||
{
|
||||
match: '/api/v1/auth/2fa/setup',
|
||||
method: 'POST',
|
||||
response: () =>
|
||||
json({
|
||||
secret: 'JBSWY3DPEHPK3PXP',
|
||||
otpauth_url: 'otpauth://totp/glchat:alice?secret=X',
|
||||
// QR-код рисует сервер и отдаёт data-URI: внешние сервисы не нужны.
|
||||
qr_png: 'data:image/png;base64,iVBORw0KGgo=',
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/auth/2fa/enable',
|
||||
method: 'POST',
|
||||
response: () => json({ recovery_codes: ['aaaa-bbbb', 'cccc-dddd'] }),
|
||||
},
|
||||
]);
|
||||
|
||||
renderApp('/settings/security');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.click(await screen.findByRole('button', { name: 'Включить 2FA' }));
|
||||
|
||||
expect(await screen.findByTestId('totp-secret')).toHaveTextContent('JBSWY3DPEHPK3PXP');
|
||||
expect(screen.getByAltText('QR-код для настройки 2FA')).toBeVisible();
|
||||
|
||||
await visitor.type(screen.getByLabelText('Код из приложения'), '123456');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Включить' }));
|
||||
|
||||
const codes = await screen.findByTestId('recovery-codes');
|
||||
expect(codes).toHaveTextContent('aaaa-bbbb');
|
||||
expect(codes).toHaveTextContent('cccc-dddd');
|
||||
expect(screen.getByText('2FA включена. Сохраните коды восстановления.')).toBeVisible();
|
||||
|
||||
expect(findRequest(fetchMock, { url: '/auth/2fa/enable', method: 'POST' })?.body).toEqual({
|
||||
code: '123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('logout-all вызывается по кнопке и уводит на /login', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
|
||||
{ match: '/api/v1/auth/logout-all', method: 'POST', response: () => json({ ok: true }) },
|
||||
]);
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
const { router } = renderApp('/settings/security');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
expect(await screen.findByTestId('sessions-list')).toHaveTextContent('Firefox on Linux');
|
||||
|
||||
await visitor.click(screen.getByRole('button', { name: 'Выйти на всех устройствах' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
expect(confirm).toHaveBeenCalled();
|
||||
expect(
|
||||
recordedRequests(fetchMock).some(
|
||||
(request) => request.url.includes('/auth/logout-all') && request.method === 'POST',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('смена пароля требует step-up и повторяется после подтверждения', async () => {
|
||||
let passwordAttempts = 0;
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
|
||||
{
|
||||
match: '/api/v1/users/@me/password',
|
||||
method: 'POST',
|
||||
response: () => {
|
||||
passwordAttempts += 1;
|
||||
return passwordAttempts === 1
|
||||
? apiError('auth.step_up_required', 403)
|
||||
: json({ ok: true });
|
||||
},
|
||||
},
|
||||
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
|
||||
]);
|
||||
|
||||
renderApp('/settings/security');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Текущий пароль'), 'old-password-1');
|
||||
await visitor.type(screen.getByLabelText('Новый пароль'), 'new-password-1');
|
||||
await visitor.type(screen.getByLabelText('Повторите новый пароль'), 'new-password-1');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Сменить пароль' }));
|
||||
|
||||
// Сервер ответил auth.step_up_required — появилась форма подтверждения.
|
||||
expect(await screen.findByText('Подтвердите личность')).toBeVisible();
|
||||
await visitor.type(screen.getByLabelText('Ваш пароль'), 'old-password-1');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Подтвердить' }));
|
||||
|
||||
expect(await screen.findByText('Пароль изменён.')).toBeVisible();
|
||||
expect(
|
||||
recordedRequests(fetchMock).filter(
|
||||
(request) => request.url.includes('/users/@me/password') && request.method === 'POST',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('профиль сохраняется через PATCH /users/@me', async () => {
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{
|
||||
match: '/api/v1/users/@me',
|
||||
method: 'PATCH',
|
||||
response: () => json({ user: { ...user, display_name: 'Алиса' } }),
|
||||
},
|
||||
]);
|
||||
|
||||
renderApp('/settings/profile');
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
const displayName = await screen.findByLabelText('Отображаемое имя');
|
||||
await visitor.clear(displayName);
|
||||
await visitor.type(displayName, 'Алиса');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
|
||||
expect(await screen.findByText('Профиль сохранён.')).toBeVisible();
|
||||
const patchCall = findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' });
|
||||
expect(patchCall?.body).toMatchObject({ display_name: 'Алиса' });
|
||||
});
|
||||
|
||||
it('вкладка «Инстанс» скрыта от не-администратора', async () => {
|
||||
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
|
||||
|
||||
renderApp('/settings/profile');
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible();
|
||||
expect(screen.queryByRole('link', { name: 'Инстанс' })).toBeNull();
|
||||
});
|
||||
|
||||
it('администратор видит данные инстанса', async () => {
|
||||
const admin = makeUser({ is_instance_admin: true });
|
||||
installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user: admin }) },
|
||||
{
|
||||
match: '/api/v1/instance/settings',
|
||||
response: () => json({ settings: { motd: 'привет' } }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/instance/guilds',
|
||||
response: () =>
|
||||
json({
|
||||
guilds: [{ id: 'g-1', name: 'Main', member_count: 3, owner_id: 'u', is_main: true }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/instance/users',
|
||||
response: () =>
|
||||
json({
|
||||
users: [
|
||||
{
|
||||
id: 'u-1',
|
||||
username: 'alice',
|
||||
display_name: 'Alice',
|
||||
is_instance_admin: true,
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/instance/audit',
|
||||
response: () =>
|
||||
json({
|
||||
entries: [
|
||||
{
|
||||
id: 'a-1',
|
||||
actor_id: 'u-1',
|
||||
action: 'guild.create',
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
renderApp('/settings/instance');
|
||||
|
||||
expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main');
|
||||
expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice');
|
||||
expect(screen.getByTestId('instance-audit')).toHaveTextContent('guild.create');
|
||||
expect(screen.getByText('motd')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { GatewayOp } from '@/api/gateway';
|
||||
import {
|
||||
installFetch,
|
||||
json,
|
||||
makeReadySnapshot,
|
||||
makeUser,
|
||||
recordedRequests,
|
||||
renderApp,
|
||||
} from './helpers';
|
||||
|
||||
/** Подставной WebSocket: сразу отвечает HELLO и присылает READY. */
|
||||
class FakeSocket {
|
||||
static instances: FakeSocket[] = [];
|
||||
|
||||
readyState = 0;
|
||||
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(readonly url: string) {
|
||||
FakeSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(): void {
|
||||
// IDENTIFY/HEARTBEAT от клиента в этом тесте не проверяются.
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = 1;
|
||||
this.onopen?.(new Event('open'));
|
||||
}
|
||||
|
||||
emit(packet: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function installGatewaySocket(snapshot: unknown) {
|
||||
FakeSocket.instances = [];
|
||||
vi.stubGlobal('WebSocket', FakeSocket);
|
||||
return {
|
||||
/** Ждёт, пока GatewayBridge откроет сокет, и проигрывает рукопожатие. */
|
||||
async greet() {
|
||||
await waitFor(() => {
|
||||
expect(FakeSocket.instances.length).toBeGreaterThan(0);
|
||||
});
|
||||
const socket = FakeSocket.instances[0];
|
||||
if (socket === undefined) {
|
||||
throw new Error('шлюз не подключился');
|
||||
}
|
||||
socket.open();
|
||||
socket.emit({
|
||||
op: GatewayOp.HELLO,
|
||||
d: { heartbeat_interval_ms: 45_000, session_id: 'session-1' },
|
||||
});
|
||||
socket.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 1, d: snapshot });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const user = makeUser();
|
||||
|
||||
const snapshot = makeReadySnapshot([
|
||||
{
|
||||
id: 'g-1',
|
||||
name: 'Основной сервер',
|
||||
is_main: true,
|
||||
channels: [
|
||||
{ id: 'cat-1', name: 'Текстовые', type: 'category', position: 0 },
|
||||
{ id: 'c-general', name: 'общий', position: 1, parent_id: 'cat-1' },
|
||||
{ id: 'c-readonly', name: 'анонсы', position: 2, parent_id: 'cat-1', can_send: false },
|
||||
{ id: 'c-voice', name: 'Голосовая', type: 'voice', position: 3 },
|
||||
],
|
||||
},
|
||||
{ id: 'g-2', name: 'Второй сервер' },
|
||||
]);
|
||||
|
||||
describe('основной экран', () => {
|
||||
it('рендерит серверы и комнаты из снапшота READY', async () => {
|
||||
installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
]);
|
||||
const gateway = installGatewaySocket(snapshot);
|
||||
|
||||
renderApp('/app/g-1/c-general');
|
||||
// Ждём, пока AuthGuard отдаст профиль и смонтируется оболочка.
|
||||
await screen.findByTestId('guild-rail');
|
||||
await gateway.greet();
|
||||
|
||||
// Серверы в рейке.
|
||||
const rail = screen.getByTestId('guild-rail');
|
||||
await waitFor(() => {
|
||||
expect(within(rail).getAllByRole('link')).toHaveLength(2);
|
||||
});
|
||||
expect(within(rail).getByLabelText('Открыть сервер Второй сервер')).toBeVisible();
|
||||
|
||||
// Комнаты: категория, текстовая, «только чтение» (can_send=false) и голосовая.
|
||||
expect(await screen.findByLabelText('Открыть комнату общий')).toBeVisible();
|
||||
expect(screen.getByLabelText('Открыть комнату анонсы')).toBeVisible();
|
||||
expect(screen.getByLabelText('Открыть комнату Голосовая')).toBeVisible();
|
||||
expect(screen.getByRole('heading', { name: 'Текстовые' })).toBeVisible();
|
||||
|
||||
// Панель пользователя.
|
||||
const panel = screen.getByLabelText('Панель пользователя');
|
||||
expect(within(panel).getByText('Alice')).toBeVisible();
|
||||
expect(within(panel).getByLabelText('Настройки пользователя')).toBeVisible();
|
||||
expect(within(panel).getByLabelText('Выйти')).toBeVisible();
|
||||
|
||||
// Заглушка комнаты вместо чата Фазы 1: шапка комнаты + подсказка.
|
||||
expect(await screen.findByText('Чат появится в Фазе 2')).toBeVisible();
|
||||
expect(screen.getByRole('heading', { name: 'общий' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('пустой список серверов предлагает главный сервер и создание своего', async () => {
|
||||
installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{
|
||||
match: '/api/v1/instance',
|
||||
response: () =>
|
||||
json({
|
||||
instance: {
|
||||
name: 'glchat-test',
|
||||
registration_enabled: true,
|
||||
allow_guild_creation: true,
|
||||
max_guilds_per_user: 5,
|
||||
main_guild_id: 'g-main',
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
|
||||
]);
|
||||
|
||||
renderApp('/app/empty');
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByLabelText('Создать сервер')).toBeVisible();
|
||||
});
|
||||
|
||||
it('после вступления в главный сервер открывает его в URL', async () => {
|
||||
const { default: userEvent } = await import('@testing-library/user-event');
|
||||
const visitor = userEvent.setup();
|
||||
const mainGuild = {
|
||||
id: 'g-main',
|
||||
name: 'Главный',
|
||||
owner_id: 'user-1',
|
||||
is_main: true,
|
||||
member_count: 5,
|
||||
my_role_ids: [],
|
||||
my_permissions: [],
|
||||
};
|
||||
let joined = false;
|
||||
installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{
|
||||
match: '/api/v1/instance',
|
||||
response: () =>
|
||||
json({
|
||||
instance: {
|
||||
name: 'glchat-test',
|
||||
registration_enabled: true,
|
||||
allow_guild_creation: true,
|
||||
max_guilds_per_user: 5,
|
||||
main_guild_id: 'g-main',
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/guilds/g-main/join',
|
||||
method: 'POST',
|
||||
response: () => {
|
||||
joined = true;
|
||||
return json({ ok: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
match: '/api/v1/users/@me/guilds',
|
||||
response: () => json({ guilds: joined ? [mainGuild] : [] }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/guilds/g-main/channels',
|
||||
response: () =>
|
||||
json({ channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }] }),
|
||||
},
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/app/empty');
|
||||
await visitor.click(
|
||||
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
|
||||
);
|
||||
|
||||
// В снапшоте сервера может не быть — REST-подстраховка обновляет список,
|
||||
// после чего пользователь попадает в первую комнату сервера.
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toStrictEqual(expect.stringContaining('/app/g-main'));
|
||||
});
|
||||
});
|
||||
|
||||
it('создаёт сервер через модальное окно', async () => {
|
||||
const { default: userEvent } = await import('@testing-library/user-event');
|
||||
const visitor = userEvent.setup();
|
||||
const created = {
|
||||
id: 'g-new',
|
||||
name: 'Новый',
|
||||
owner_id: 'user-1',
|
||||
is_main: false,
|
||||
member_count: 1,
|
||||
my_role_ids: [],
|
||||
my_permissions: [],
|
||||
};
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{
|
||||
match: '/api/v1/instance',
|
||||
response: () =>
|
||||
json({
|
||||
instance: {
|
||||
name: 'glchat-test',
|
||||
registration_enabled: true,
|
||||
allow_guild_creation: true,
|
||||
max_guilds_per_user: 5,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/guilds', method: 'POST', response: () => json({ guild: created }) },
|
||||
{ match: '/api/v1/guilds/g-new/channels', response: () => json({ channels: [] }) },
|
||||
{ match: '/api/v1/guilds/g-new', response: () => json({ guild: { ...created, roles: [] } }) },
|
||||
]);
|
||||
|
||||
const { router } = renderApp('/app/empty');
|
||||
await visitor.click(await screen.findByLabelText('Создать сервер'));
|
||||
|
||||
await visitor.type(await screen.findByLabelText('Название сервера'), 'Новый');
|
||||
await visitor.click(screen.getByRole('button', { name: 'Создать' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/app/g-new');
|
||||
});
|
||||
const createCall = recordedRequests(fetchMock).find(
|
||||
(request) => request.url.endsWith('/api/v1/guilds') && request.method === 'POST',
|
||||
);
|
||||
expect(createCall?.body).toEqual({ name: 'Новый' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user