diff --git a/internal/server/api_instance.go b/internal/server/api_instance.go index e4c503c..c2af861 100644 --- a/internal/server/api_instance.go +++ b/internal/server/api_instance.go @@ -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 }) diff --git a/internal/server/api_users.go b/internal/server/api_users.go index 9e94cb8..3f09ca5 100644 --- a/internal/server/api_users.go +++ b/internal/server/api_users.go @@ -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), }) } diff --git a/internal/server/auth.go b/internal/server/auth.go index 5e13dd6..18f3a72 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -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) { diff --git a/internal/server/openapi.go b/internal/server/openapi.go index 34cb392..215f49d 100644 --- a/internal/server/openapi.go +++ b/internal/server/openapi.go @@ -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" } } }, diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index ef9192e..2507b42 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -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 { + return request('/auth/2fa/setup', { method: 'POST' }); } export async function enableTotp(code: string): Promise { diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts index b307491..759a94d 100644 --- a/web/src/api/gateway.ts +++ b/web/src/api/gateway.ts @@ -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; diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts new file mode 100644 index 0000000..11d53d4 --- /dev/null +++ b/web/src/api/gatewayEvents.ts @@ -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 | null { + return typeof value === 'object' && value !== null ? (value as Record) : 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; +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 456abff..4296cd8 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c4f0838..c8f8421 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 367578e..7a95d33 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "главный" } } diff --git a/web/src/lib/identity.ts b/web/src/lib/identity.ts index ba42c49..a3bac49 100644 --- a/web/src/lib/identity.ts +++ b/web/src/lib/identity.ts @@ -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); } /** Может ли пользователь управлять сервером (создавать комнаты и т.п.). */ diff --git a/web/src/pages/app/AppLayout.tsx b/web/src/pages/app/AppLayout.tsx index bdb7136..92c4d45 100644 --- a/web/src/pages/app/AppLayout.tsx +++ b/web/src/pages/app/AppLayout.tsx @@ -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')} +
)} - {limitReached && errorCode(create.error) === null ? ( -

{t('errors.auth.guild_limit_reached')}

+ {limitReached && !create.isError ? ( +

+ {t('guilds.create.limitReached')} +

) : null} {create.isError ? : null} diff --git a/web/src/pages/app/ChannelSidebar.tsx b/web/src/pages/app/ChannelSidebar.tsx index 81ebae1..094c590 100644 --- a/web/src/pages/app/ChannelSidebar.tsx +++ b/web/src/pages/app/ChannelSidebar.tsx @@ -140,6 +140,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) { fetchCurrentUser(signal), + }); + const twoFactorEnabled = currentUser.data?.totp_enabled === true; return (
@@ -25,7 +29,12 @@ export default function SecuritySettingsPage() { - setTwoFactorEnabled(true)} /> + { + void currentUser.refetch(); + }} + />
); } diff --git a/web/src/pages/settings/TwoFactorSection.tsx b/web/src/pages/settings/TwoFactorSection.tsx index 6116515..90e85ae 100644 --- a/web/src/pages/settings/TwoFactorSection.tsx +++ b/web/src/pages/settings/TwoFactorSection.tsx @@ -21,6 +21,7 @@ export function TwoFactorSection({ const { t } = useTranslation(); const [secret, setSecret] = useState(null); const [otpauthUrl, setOtpauthUrl] = useState(''); + const [qrPng, setQrPng] = useState(''); const [code, setCode] = useState(''); const [recoveryCodes, setRecoveryCodes] = useState(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({

{t('settings.security.twoFactorSetupHint')}

- {qrFailed || otpauthUrl === '' ? ( + {qrFailed || qrPng === '' ? (

{t('settings.security.twoFactorQrUnavailable')}

) : ( {t('settings.security.twoFactorQrAlt')} { + 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 { + 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 { + 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; diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index 343bc07..da32598 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -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((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((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((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((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 => diff --git a/web/tests/api.test.ts b/web/tests/api.test.ts new file mode 100644 index 0000000..4cbeb5d --- /dev/null +++ b/web/tests/api.test.ts @@ -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) { + 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); + }); +}); diff --git a/web/tests/app.test.tsx b/web/tests/app.test.tsx index cf57d75..dbbfd60 100644 --- a/web/tests/app.test.tsx +++ b/web/tests/app.test.tsx @@ -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( - - - , - ); -} - -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( () => { diff --git a/web/tests/gateway.test.ts b/web/tests/gateway.test.ts index a5a9629..b673c0b 100644 --- a/web/tests/gateway.test.ts +++ b/web/tests/gateway.test.ts @@ -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(); + }); }); diff --git a/web/tests/gatewayEvents.test.ts b/web/tests/gatewayEvents.test.ts new file mode 100644 index 0000000..4e5eb77 --- /dev/null +++ b/web/tests/gatewayEvents.test.ts @@ -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); + }); +}); diff --git a/web/tests/guard.test.tsx b/web/tests/guard.test.tsx new file mode 100644 index 0000000..b6c8170 --- /dev/null +++ b/web/tests/guard.test.tsx @@ -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'); + }); +}); diff --git a/web/tests/helpers.tsx b/web/tests/helpers.tsx index 853928b..776fbee 100644 --- a/web/tests/helpers.tsx +++ b/web/tests/helpers.tsx @@ -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): 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, + 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 { badges: [], locale: 'ru', onboarding_completed: true, + totp_enabled: false, ...overrides, }; } diff --git a/web/tests/login.test.tsx b/web/tests/login.test.tsx index ffc1234..06064e7 100644 --- a/web/tests/login.test.tsx +++ b/web/tests/login.test.tsx @@ -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', diff --git a/web/tests/register.test.tsx b/web/tests/register.test.tsx index 3fe2109..ec615b4 100644 --- a/web/tests/register.test.tsx +++ b/web/tests/register.test.tsx @@ -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); }); diff --git a/web/tests/session.test.ts b/web/tests/session.test.ts index 8276740..94ab125 100644 --- a/web/tests/session.test.ts +++ b/web/tests/session.test.ts @@ -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(); diff --git a/web/tests/settings.test.tsx b/web/tests/settings.test.tsx new file mode 100644 index 0000000..82ef3bb --- /dev/null +++ b/web/tests/settings.test.tsx @@ -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(); + }); +}); diff --git a/web/tests/shell.test.tsx b/web/tests/shell.test.tsx new file mode 100644 index 0000000..4fa7ead --- /dev/null +++ b/web/tests/shell.test.tsx @@ -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: 'Новый' }); + }); +});