From bf0d130ee803a6864d9c32a2b61641d22a14585f Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 19 Sep 2026 21:53:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=202FA=20=D0=B8=D0=BD=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D1=81-=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=BE=D0=B9=20totp-setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2FA обязательна для инстанс-администраторов, но до её включения вход закрыт — получался замкнутый круг. Добавлены команды обслуживания: - `glchat totp-setup --email `: создаёт секрет, подтверждает его кодом, печатает секрет, otpauth-ссылку и 8 резервных кодов (каждый одноразовый); - `glchat totp-reset --email `: удаляет секрет при потере устройства; - код `auth.2fa_enrollment_required` с подсказкой, какую команду выполнить; - установщик: флаг `--admin-2fa` для автоматического включения (по умолчанию печатает подсказку, чтобы секреты не оседали в логах установки). Проверено сквозным прогоном локально: bootstrap → 403 на входе без 2FA → totp-setup → вход с TOTP-кодом → профиль, серверы, комнаты, роли, участники, аудит, создание сервера админом, 429 на шестой попытке входа, секретов в логах нет. --- cmd/glchat/cli.go | 88 ++++++++ cmd/glchat/main.go | 5 +- deploy/install.sh | 15 ++ internal/server/errors.go | 4 +- web/src/App.tsx | 85 +------- web/src/api/gateway.ts | 19 +- web/src/components/AuthGuard.tsx | 16 +- web/src/lib/channels.ts | 35 ++++ web/src/lib/http.ts | 25 +++ web/src/pages/IndexRedirect.tsx | 17 ++ web/src/pages/OnboardingPage.tsx | 169 ++++++++-------- web/src/pages/app/AppLayout.tsx | 11 +- web/src/pages/app/ChannelSidebar.tsx | 39 +--- web/src/pages/lazyPages.ts | 19 ++ .../pages/settings/AppearanceSettingsPage.tsx | 4 +- .../pages/settings/ProfileSettingsPage.tsx | 86 ++++---- web/src/router.tsx | 69 +++++++ web/tests/gateway.test.ts | 191 ++++++++++++++++++ web/tests/helpers.tsx | 176 ++++++++++++++++ web/tests/login.test.tsx | 100 +++++++++ web/tests/register.test.tsx | 96 +++++++++ web/tests/session.test.ts | 110 ++++++++++ web/tests/setup.ts | 5 + 23 files changed, 1115 insertions(+), 269 deletions(-) create mode 100644 web/src/lib/channels.ts create mode 100644 web/src/lib/http.ts create mode 100644 web/src/pages/IndexRedirect.tsx create mode 100644 web/src/pages/lazyPages.ts create mode 100644 web/src/router.tsx create mode 100644 web/tests/gateway.test.ts create mode 100644 web/tests/helpers.tsx create mode 100644 web/tests/login.test.tsx create mode 100644 web/tests/register.test.tsx create mode 100644 web/tests/session.test.ts diff --git a/cmd/glchat/cli.go b/cmd/glchat/cli.go index 7d16986..f69c68d 100644 --- a/cmd/glchat/cli.go +++ b/cmd/glchat/cli.go @@ -2,9 +2,13 @@ package main import ( "context" + "errors" "flag" "fmt" "os" + "time" + + "github.com/pquerna/otp/totp" "glchat/internal/auth" "glchat/internal/bootstrap" @@ -23,6 +27,10 @@ func runCLI(args []string) int { return cliResetPassword(args[1:]) case "make-admin", "remove-admin": return cliSetInstanceAdmin(args[0], args[1:]) + case "totp-setup": + return cliTOTPSetup(args[1:]) + case "totp-reset": + return cliTOTPReset(args[1:]) default: fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0]) return 2 @@ -136,6 +144,86 @@ func cliResetPassword(args []string) int { }) } +// cliTOTPSetup включает 2FA для пользователя (обычно инстанс-администратора): +// 2FA обязательна для админов (AGENT.md 7.19), а до её включения вход закрыт, +// поэтому первичная настройка выполняется командой обслуживания. +func cliTOTPSetup(args []string) int { + flags := flag.NewFlagSet("totp-setup", flag.ContinueOnError) + email := flags.String("email", "", "email пользователя (обычно администратора инстанса)") + positional := flags.Args() + if err := flags.Parse(args); err != nil { + return 2 + } + if *email == "" && len(positional) > 0 { + *email = positional[0] + } + if *email == "" { + fmt.Fprintln(os.Stderr, "glchat totp-setup: укажите --email") + return 2 + } + + return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error { + user, err := authService.UserByEmail(ctx, *email) + if err != nil { + return fmt.Errorf("пользователь с таким email не найден") + } + setup, err := authService.SetupTOTP(ctx, user.ID) + if err != nil { + if errors.Is(err, auth.ErrTOTPAlreadyEnabled) { + return fmt.Errorf("2FA уже включена: сбросьте секрет через glchat totp-reset") + } + return err + } + // Код подтверждения считаем из секрета: команда выполняется на сервере, + // где секрет и так доступен (расшифровка идёт внутри сервиса). + code, err := totp.GenerateCode(setup.Secret, time.Now()) + if err != nil { + return fmt.Errorf("сгенерировать код подтверждения: %w", err) + } + codes, err := authService.EnableTOTP(ctx, user.ID, code) + if err != nil { + return err + } + fmt.Printf("2FA включена для %s\n", user.Username) + fmt.Printf("секрет: %s\n", setup.Secret) + fmt.Printf("otpauth: %s\n", setup.URL) + fmt.Println("резервные коды (каждый одноразовый, сохраните вне сервера):") + for _, recovery := range codes { + fmt.Printf(" %s\n", recovery) + } + return nil + }) +} + +// cliTOTPReset удаляет секрет 2FA (например, при потере устройства и кодов). +func cliTOTPReset(args []string) int { + flags := flag.NewFlagSet("totp-reset", flag.ContinueOnError) + email := flags.String("email", "", "email пользователя") + positional := flags.Args() + if err := flags.Parse(args); err != nil { + return 2 + } + if *email == "" && len(positional) > 0 { + *email = positional[0] + } + if *email == "" { + fmt.Fprintln(os.Stderr, "glchat totp-reset: укажите --email") + return 2 + } + + return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error { + user, err := authService.UserByEmail(ctx, *email) + if err != nil { + return fmt.Errorf("пользователь с таким email не найден") + } + if err := st.DeleteTOTPSecret(ctx, user.ID); err != nil { + return err + } + fmt.Printf("секрет 2FA для %s удалён; включите заново командой totp-setup\n", user.Username) + return nil + }) +} + func cliSetInstanceAdmin(command string, args []string) int { flags := flag.NewFlagSet(command, flag.ContinueOnError) email := flags.String("email", "", "email пользователя") diff --git a/cmd/glchat/main.go b/cmd/glchat/main.go index d70a163..9223ec3 100644 --- a/cmd/glchat/main.go +++ b/cmd/glchat/main.go @@ -37,10 +37,11 @@ func main() { os.Exit(healthcheck()) } // Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password, - // make-admin, remove-admin. Остальные команды реализует shell-обёртка glchat. + // make-admin, remove-admin, totp-setup, totp-reset. Остальные команды + // реализует shell-обёртка glchat. if len(os.Args) > 1 { switch os.Args[1] { - case "bootstrap-admin", "reset-password", "make-admin", "remove-admin": + case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset": os.Exit(runCLI(os.Args[1:])) } } diff --git a/deploy/install.sh b/deploy/install.sh index 4ab9b9c..a4c142e 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -78,6 +78,7 @@ PROFILE="${PROFILE:-auto}" INSTANCE_NAME="${INSTANCE_NAME:-glchat}" MAIN_GUILD_NAME="${MAIN_GUILD_NAME:-Главный сервер}" ADMIN_EMAIL="${ADMIN_EMAIL:-}" +ENABLE_ADMIN_2FA="${ENABLE_ADMIN_2FA:-0}" ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" REGISTRATION_ENABLED="${REGISTRATION_ENABLED:-true}" TURN_ENABLED="${TURN_ENABLED:-true}" @@ -118,6 +119,9 @@ install.sh — установка glchat (AGENT.md 10). --email email для Let's Encrypt (обязателен, если TLS включён) --admin логин инстанс-администратора --password пароль инстанс-администратора (иначе — интерактивный ввод) + --admin-2fa включить 2FA инстанс-админу и напечатать секрет и + резервные коды (по умолчанию включается вручную: + glchat totp-setup --email ) --instance-name название инстанса (по умолчанию glchat) Сеть: @@ -183,6 +187,7 @@ parse_args() { --email) ACME_EMAIL="${2:?}"; shift 2 ;; --admin) ADMIN_EMAIL="${2:?}"; shift 2 ;; --password) ADMIN_PASSWORD="${2:?}"; shift 2 ;; + --admin-2fa) ENABLE_ADMIN_2FA=1; shift ;; --instance-name) INSTANCE_NAME="${2:?}"; shift 2 ;; --main-guild) MAIN_GUILD_NAME="${2:?}"; shift 2 ;; --ssh-port) SSH_PORT="${2:?}"; shift 2 ;; @@ -1463,6 +1468,16 @@ bootstrap_admin() { else warn "bootstrap-admin недоступен в этой версии образа (появится в фазе 1); данные сохранены в .env" fi + # 2FA обязательна для инстанс-админов (AGENT.md 7.19): секрет и резервные + # коды печатает команда totp-setup. По умолчанию не выполняем её, чтобы + # секреты не оседали в логах установки. + if [ "${ENABLE_ADMIN_2FA:-0}" = "1" ]; then + info "включение 2FA для инстанс-админа" + docker exec glchat-app /usr/local/bin/glchat totp-setup --email "$ADMIN_EMAIL" \ + || warn "не удалось включить 2FA автоматически: выполните glchat totp-setup --email $ADMIN_EMAIL" + else + info "2FA инстанс-админа включается вручную: glchat totp-setup --email $ADMIN_EMAIL" + fi } run_smoke() { diff --git a/internal/server/errors.go b/internal/server/errors.go index f892ac4..b50d119 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -33,8 +33,8 @@ func newAPIError(err error) apiError { case errors.Is(err, auth.ErrTOTPInvalid): candidate.Status, candidate.Code, candidate.Message = http.StatusBadRequest, "auth.totp_invalid", "invalid two-factor code" case errors.Is(err, auth.ErrInstanceAdminTOTP): - candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_required" - candidate.Message = "instance administrators must enable two-factor authentication" + candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_enrollment_required" + candidate.Message = "instance administrator must enable two-factor authentication: run `glchat totp-setup --email ` on the server" case errors.Is(err, auth.ErrSessionExpired): candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired" case errors.Is(err, auth.ErrStepUpRequired): diff --git a/web/src/App.tsx b/web/src/App.tsx index 0ca507b..9d91bc5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,38 +1,11 @@ -import { lazy, Suspense } from 'react'; -import { createBrowserRouter, Navigate, RouterProvider } from 'react-router'; +import { Suspense } from 'react'; +import { RouterProvider } from 'react-router'; -import { AuthGuard } from '@/components/AuthGuard'; import { LoadingNotice } from '@/components/ui/ErrorNotice'; -import { useCurrentUser } from '@/lib/hooks'; +import { router } from '@/router'; import '@/i18n'; -const LoginPage = lazy(() => import('@/pages/LoginPage')); -const RegisterPage = lazy(() => import('@/pages/RegisterPage')); -const OnboardingPage = lazy(() => import('@/pages/OnboardingPage')); -const StatusPage = lazy(() => import('@/pages/StatusPage')); -const AppLayout = lazy(() => import('@/pages/app/AppLayout')); -const GuildView = lazy(() => import('@/pages/app/GuildView')); -const NoGuildView = lazy(() => import('@/pages/app/NoGuildView')); -const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout')); -const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage')); -const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage')); -const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage')); -const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage')); - -/** `/` — редирект по состоянию сессии. */ -function IndexRedirect() { - const currentUser = useCurrentUser(); - if (currentUser.isPending) { - return ( -
- -
- ); - } - return ; -} - -/** Общая заглушка на время загрузки ленивых страниц. */ +/** Заглушка на время загрузки ленивых страниц. */ export function RouteFallback() { return (
@@ -41,56 +14,6 @@ export function RouteFallback() { ); } -export const routes = [ - { - path: '/', - children: [ - { index: true, element: }, - { path: 'login', element: }, - { path: 'register', element: }, - { path: 'status', element: }, - { - element: , - children: [{ path: 'onboarding', element: }], - }, - { - element: , - children: [ - { - path: 'app', - element: , - children: [ - { index: true, element: }, - { path: 'empty', element: }, - { path: ':guildId', element: }, - { path: ':guildId/:channelId', element: }, - ], - }, - ], - }, - { - element: , - children: [ - { - path: 'settings', - element: , - children: [ - { index: true, element: }, - { path: 'profile', element: }, - { path: 'security', element: }, - { path: 'appearance', element: }, - { path: 'instance', element: }, - ], - }, - ], - }, - { path: '*', element: }, - ], - }, -]; - -export const router = createBrowserRouter(routes); - export function App() { return ( }> diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts index d7290f8..b307491 100644 --- a/web/src/api/gateway.ts +++ b/web/src/api/gateway.ts @@ -1,3 +1,5 @@ +import { describeUnknown } from '@/lib/http'; + import type { Channel, Role, UserStatus } from './types'; /** @@ -96,11 +98,16 @@ export interface GatewayConnectionOptions { /** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */ socketFactory?: (url: string) => WebSocket; logger?: GatewayLogger; + /** Базовая задержка переподключения; в тестах её уменьшают. */ + baseBackoffMs?: number; + /** Сколько ждать HELLO после открытия сокета. */ + helloTimeoutMs?: number; } const DEFAULT_HEARTBEAT_MS = 45_000; const MAX_BACKOFF_MS = 30_000; const BASE_BACKOFF_MS = 1_000; +const HELLO_TIMEOUT_MS = 15_000; interface GatewayPacket { op: number; @@ -326,6 +333,8 @@ export class GatewayConnection { private readonly handlers: GatewayHandlers; private readonly socketFactory: (url: string) => WebSocket; private readonly logger: GatewayLogger; + private readonly baseBackoffMs: number; + private readonly helloTimeoutMs: number; private socket: WebSocket | null = null; private status: GatewayStatus = 'idle'; @@ -343,6 +352,8 @@ export class GatewayConnection { this.url = options.url; this.handlers = options.handlers ?? {}; this.logger = options.logger ?? { warn: () => undefined, error: () => undefined }; + this.baseBackoffMs = options.baseBackoffMs ?? BASE_BACKOFF_MS; + this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS; this.socketFactory = options.socketFactory ?? ((url) => { @@ -403,13 +414,13 @@ export class GatewayConnection { try { socket = this.socketFactory(this.url); } catch (error) { - this.logger.warn(`gateway: cannot open socket (${String(error)})`); + this.logger.warn(`gateway: cannot open socket (${describeUnknown(error)})`); this.scheduleReconnect(); return; } this.socket = socket; if (reason !== undefined) { - this.logger.warn(`gateway: reconnecting (${String(reason)})`); + this.logger.warn(`gateway: reconnecting (${describeUnknown(reason)})`); } socket.onopen = () => { @@ -417,7 +428,7 @@ export class GatewayConnection { this.helloTimeout = setTimeout(() => { this.logger.warn('gateway: HELLO timeout'); this.dropSocket(); - }, 15_000); + }, this.helloTimeoutMs); }; socket.onmessage = (event: MessageEvent) => { @@ -464,7 +475,7 @@ export class GatewayConnection { return; } this.attempt += 1; - const backoff = Math.min(BASE_BACKOFF_MS * 2 ** (this.attempt - 1), MAX_BACKOFF_MS); + const backoff = Math.min(this.baseBackoffMs * 2 ** (this.attempt - 1), MAX_BACKOFF_MS); const jitter = Math.floor(Math.random() * 250); this.setStatus('reconnecting'); this.reconnectTimer = setTimeout(() => { diff --git a/web/src/components/AuthGuard.tsx b/web/src/components/AuthGuard.tsx index 5371005..33a6ae9 100644 --- a/web/src/components/AuthGuard.tsx +++ b/web/src/components/AuthGuard.tsx @@ -2,6 +2,7 @@ import { Navigate, Outlet, useLocation } from 'react-router'; import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice'; import { useCurrentUser } from '@/lib/hooks'; +import { isUnauthorized } from '@/lib/http'; interface AuthGuardProps { /** @@ -45,18 +46,3 @@ export function AuthGuard({ allowIncompleteOnboarding = false }: AuthGuardProps) return ; } - -/** Коды, которые означают именно отсутствие сессии. */ -const unauthorizedCodes = new Set(['auth.unauthorized', 'auth.session_invalid', 'auth.required']); - -/** 401/403 или один из «сессионных» кодов; остальное — сеть или ошибка сервера. */ -export function isUnauthorized(error: unknown): boolean { - if (typeof error !== 'object' || error === null) { - return false; - } - const candidate = error as { status?: unknown; code?: unknown }; - if (candidate.status === 401) { - return true; - } - return typeof candidate.code === 'string' && unauthorizedCodes.has(candidate.code); -} diff --git a/web/src/lib/channels.ts b/web/src/lib/channels.ts new file mode 100644 index 0000000..ac47d8f --- /dev/null +++ b/web/src/lib/channels.ts @@ -0,0 +1,35 @@ +import type { Channel } from '@/api/types'; + +export interface ChannelGroup { + category: Channel | null; + channels: Channel[]; +} + +/** + * Группирует комнаты по категориям, сохраняя порядок `position`. + * Невидимые комнаты (`can_view === false`) сервер обычно не присылает, + * но если прислал — прячем их на клиенте. + */ +export function groupChannels(channels: Channel[]): ChannelGroup[] { + const visible = channels + .filter((channel) => channel.can_view !== false && channel.type !== 'category') + .slice() + .sort((left, right) => left.position - right.position); + const categories = channels + .filter((channel) => channel.type === 'category') + .slice() + .sort((left, right) => left.position - right.position); + + const groups: ChannelGroup[] = []; + const uncategorized = visible.filter((channel) => channel.parent_id === undefined); + if (uncategorized.length > 0) { + groups.push({ category: null, channels: uncategorized }); + } + for (const category of categories) { + const children = visible.filter((channel) => channel.parent_id === category.id); + if (children.length > 0) { + groups.push({ category, channels: children }); + } + } + return groups; +} diff --git a/web/src/lib/http.ts b/web/src/lib/http.ts new file mode 100644 index 0000000..68dd732 --- /dev/null +++ b/web/src/lib/http.ts @@ -0,0 +1,25 @@ +/** Коды, которые означают именно отсутствие сессии. */ +const unauthorizedCodes = new Set(['auth.unauthorized', 'auth.session_invalid', 'auth.required']); + +/** 401 или один из «сессионных» кодов; остальное — сеть или ошибка сервера. */ +export function isUnauthorized(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + const candidate = error as { status?: unknown; code?: unknown }; + if (candidate.status === 401) { + return true; + } + return typeof candidate.code === 'string' && unauthorizedCodes.has(candidate.code); +} + +/** Есть ли у объекта осмысленное строковое описание ошибки. */ +export function describeUnknown(value: unknown): string { + if (value instanceof Error) { + return value.message; + } + if (typeof value === 'string') { + return value; + } + return 'unknown error'; +} diff --git a/web/src/pages/IndexRedirect.tsx b/web/src/pages/IndexRedirect.tsx new file mode 100644 index 0000000..713b265 --- /dev/null +++ b/web/src/pages/IndexRedirect.tsx @@ -0,0 +1,17 @@ +import { Navigate } from 'react-router'; + +import { LoadingNotice } from '@/components/ui/ErrorNotice'; +import { useCurrentUser } from '@/lib/hooks'; + +/** `/` — редирект по состоянию сессии: в приложение или на вход. */ +export default function IndexRedirect() { + const currentUser = useCurrentUser(); + if (currentUser.isPending) { + return ( +
+ +
+ ); + } + return ; +} diff --git a/web/src/pages/OnboardingPage.tsx b/web/src/pages/OnboardingPage.tsx index 007a13d..07ba428 100644 --- a/web/src/pages/OnboardingPage.tsx +++ b/web/src/pages/OnboardingPage.tsx @@ -1,50 +1,35 @@ -import { useEffect, useState, type FormEvent } from 'react'; +import { useState, type FormEvent } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { completeOnboarding, currentUserQueryKey } from '@/api/users'; import type { OnboardingInput } from '@/api/users'; -import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice'; import { Field, SelectField, TextAreaField } from '@/components/ui/Field'; import { Button, Card } from '@/components/ui/primitives'; import { supportedLanguages, type SupportedLanguage } from '@/i18n'; import { useCurrentUser } from '@/lib/hooks'; import { useSessionStore } from '@/stores/session'; -import { useUiStore, type Theme } from '@/stores/ui'; +import { useUiStore } from '@/stores/ui'; -/** Первичная настройка: имя, «о себе», язык и тема, затем POST onboarding/complete. */ -export default function OnboardingPage() { +interface OnboardingFormProps { + /** Имя, предложенное сервером (или из снапшота шлюза). */ + suggestedName: string; + preferredLocale: SupportedLanguage; +} + +/** Форма онбординга: имя, «о себе», язык и тема + POST onboarding/complete. */ +function OnboardingForm({ suggestedName, preferredLocale }: OnboardingFormProps) { const { t, i18n } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const currentUser = useCurrentUser(); const theme = useUiStore((state) => state.theme); const setTheme = useUiStore((state) => state.setTheme); - const sessionUser = useSessionStore((state) => state.user); - const [displayName, setDisplayName] = useState(''); + const [displayName, setDisplayName] = useState(suggestedName); const [bio, setBio] = useState(''); - const [locale, setLocale] = useState( - i18n.resolvedLanguage === 'en' ? 'en' : 'ru', - ); - const [touched, setTouched] = useState(false); - - const user = currentUser.data; - const suggestedName = user?.display_name ?? sessionUser?.display_name ?? user?.username ?? ''; - - useEffect(() => { - if (suggestedName !== '' && !touched) { - setDisplayName((current) => (current === '' ? suggestedName : current)); - } - }, [suggestedName, touched]); - - useEffect(() => { - const preferred = user?.locale; - if (preferred === 'en' || preferred === 'ru') { - setLocale(preferred); - } - }, [user?.locale]); + const [locale, setLocale] = useState(preferredLocale); const complete = useMutation({ mutationFn: (input: OnboardingInput) => completeOnboarding(input), @@ -61,13 +46,8 @@ export default function OnboardingPage() { void i18n.changeLanguage(language); }; - const changeTheme = (next: Theme): void => { - setTheme(next); - }; - const onSubmit = (event: FormEvent): void => { event.preventDefault(); - setTouched(true); const input: OnboardingInput = { locale }; const trimmedName = displayName.trim(); const trimmedBio = bio.trim(); @@ -80,7 +60,75 @@ export default function OnboardingPage() { complete.mutate(input); }; - const unknownUser = currentUser.isError && !complete.isSuccess; + return ( + +
+ setDisplayName(event.target.value)} + maxLength={64} + /> + setBio(event.target.value)} + /> + changeLanguage(value === 'en' ? 'en' : 'ru')} + options={supportedLanguages.map((language) => ({ + value: language, + label: t(`language.${language}`), + }))} + /> + setTheme(value === 'light' ? 'light' : 'dark')} + options={[ + { value: 'dark', label: t('theme.dark') }, + { value: 'light', label: t('theme.light') }, + ]} + /> + + {complete.isError ? : null} + +
+

{t('onboarding.note')}

+ +
+ +
+ ); +} + +/** Первичная настройка: приветствие, имя, «о себе», язык и тема. */ +export default function OnboardingPage() { + const { t } = useTranslation(); + const currentUser = useCurrentUser(); + const sessionUser = useSessionStore((state) => state.user); + + const user = currentUser.data; + const suggestedName = user?.display_name ?? sessionUser?.display_name ?? user?.username ?? ''; + const preferredLocale: SupportedLanguage = user?.locale === 'en' ? 'en' : 'ru'; + + if (currentUser.isPending) { + return ( +
+ +
+ ); + } return (
@@ -93,58 +141,9 @@ export default function OnboardingPage() {

{t('onboarding.subtitle')}

- -
- { - setTouched(true); - setDisplayName(event.target.value); - }} - maxLength={64} - /> - setBio(event.target.value)} - /> - changeLanguage(value === 'en' ? 'en' : 'ru')} - options={supportedLanguages.map((language) => ({ - value: language, - label: t(`language.${language}`), - }))} - /> - changeTheme(value === 'light' ? 'light' : 'dark')} - options={[ - { value: 'dark', label: t('theme.dark') }, - { value: 'light', label: t('theme.light') }, - ]} - /> + - {unknownUser ? : null} - {complete.isError ? : null} - -
-

{t('onboarding.note')}

- -
- -
+ {currentUser.isError ? : null}
); } diff --git a/web/src/pages/app/AppLayout.tsx b/web/src/pages/app/AppLayout.tsx index 0194deb..bdb7136 100644 --- a/web/src/pages/app/AppLayout.tsx +++ b/web/src/pages/app/AppLayout.tsx @@ -49,6 +49,9 @@ export default function AppLayout() { selectChannel(guildId, channelId); }, [guildId, channelId, selectedGuildId, selectChannel]); + // `/app/empty` — служебный маршрут, а не идентификатор сервера. + const realGuildId = guildId === 'empty' ? null : guildId; + const myGuilds = useQuery({ queryKey: myGuildsQueryKey, queryFn: ({ signal }) => fetchMyGuilds(signal), @@ -65,11 +68,11 @@ export default function AppLayout() { } }, [guilds.length, myGuilds.data, upsertGuild]); - const guildInSnapshot = guildId !== null && guilds.some((guild) => guild.id === guildId); + const guildInSnapshot = realGuildId !== null && guilds.some((guild) => guild.id === realGuildId); const fallbackGuild = useQuery({ - queryKey: guildQueryKey(guildId ?? ''), - queryFn: ({ signal }) => fetchGuild(guildId ?? '', signal), - enabled: guildId !== null && !guildInSnapshot, + queryKey: guildQueryKey(realGuildId ?? ''), + queryFn: ({ signal }) => fetchGuild(realGuildId ?? '', signal), + enabled: realGuildId !== null && !guildInSnapshot, retry: 0, }); const hydrated = useRef(null); diff --git a/web/src/pages/app/ChannelSidebar.tsx b/web/src/pages/app/ChannelSidebar.tsx index 77a72b7..81ebae1 100644 --- a/web/src/pages/app/ChannelSidebar.tsx +++ b/web/src/pages/app/ChannelSidebar.tsx @@ -9,46 +9,16 @@ import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Field, SelectField } from '@/components/ui/Field'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/primitives'; +import { groupChannels } from '@/lib/channels'; import { canManageGuild } from '@/lib/identity'; import { useCurrentUser } from '@/lib/hooks'; import { useSessionStore } from '@/stores/session'; -import type { Channel } from '@/api/types'; interface ChannelSidebarProps { guildId: string | null; channelId: string | null; } -interface ChannelGroup { - category: Channel | null; - channels: Channel[]; -} - -/** Группирует комнаты по категориям, сохраняя порядок `position`. */ -export function groupChannels(channels: Channel[]): ChannelGroup[] { - const visible = channels - .filter((channel) => channel.can_view !== false && channel.type !== 'category') - .slice() - .sort((left, right) => left.position - right.position); - const categories = channels - .filter((channel) => channel.type === 'category') - .slice() - .sort((left, right) => left.position - right.position); - - const groups: ChannelGroup[] = []; - const uncategorized = visible.filter((channel) => channel.parent_id === undefined); - if (uncategorized.length > 0) { - groups.push({ category: null, channels: uncategorized }); - } - for (const category of categories) { - const children = visible.filter((channel) => channel.parent_id === category.id); - if (children.length > 0) { - groups.push({ category, channels: children }); - } - } - return groups; -} - /** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) { const { t } = useTranslation(); @@ -68,7 +38,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) { const [channelType, setChannelType] = useState<'text' | 'voice'>('text'); const [parentId, setParentId] = useState(''); - const channels = guild?.channels ?? []; + const channels = useMemo(() => guild?.channels ?? [], [guild]); const needsChannels = guildId !== null && guild !== null && channels.length === 0; const remoteChannels = useQuery({ @@ -115,7 +85,10 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) { }); const groups = useMemo(() => groupChannels(channels), [channels]); - const categories = channels.filter((channel) => channel.type === 'category'); + const categories = useMemo( + () => channels.filter((channel) => channel.type === 'category'), + [channels], + ); const canManage = guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id); diff --git a/web/src/pages/lazyPages.ts b/web/src/pages/lazyPages.ts new file mode 100644 index 0000000..744e454 --- /dev/null +++ b/web/src/pages/lazyPages.ts @@ -0,0 +1,19 @@ +import { lazy } from 'react'; + +/** + * Ленивые страницы приложения. Лежат отдельно от таблицы маршрутов, чтобы + * модуль с `routes`/`router` не экспортировал компоненты (react-refresh). + */ +export const IndexRedirect = lazy(() => import('@/pages/IndexRedirect')); +export const LoginPage = lazy(() => import('@/pages/LoginPage')); +export const RegisterPage = lazy(() => import('@/pages/RegisterPage')); +export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage')); +export const StatusPage = lazy(() => import('@/pages/StatusPage')); +export const AppLayout = lazy(() => import('@/pages/app/AppLayout')); +export const GuildView = lazy(() => import('@/pages/app/GuildView')); +export const NoGuildView = lazy(() => import('@/pages/app/NoGuildView')); +export const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout')); +export const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage')); +export const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage')); +export const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage')); +export const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage')); diff --git a/web/src/pages/settings/AppearanceSettingsPage.tsx b/web/src/pages/settings/AppearanceSettingsPage.tsx index b1885b6..c552d2f 100644 --- a/web/src/pages/settings/AppearanceSettingsPage.tsx +++ b/web/src/pages/settings/AppearanceSettingsPage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { SelectField } from '@/components/ui/Field'; import { Card } from '@/components/ui/primitives'; import { supportedLanguages, type SupportedLanguage } from '@/i18n'; -import { useUiStore, type Theme } from '@/stores/ui'; +import { useUiStore } from '@/stores/ui'; /** Внешний вид: тема и язык. Хранятся локально, без запросов к серверу. */ export default function AppearanceSettingsPage() { @@ -27,7 +27,7 @@ export default function AppearanceSettingsPage() { label={t('settings.appearance.theme')} hint={t('settings.appearance.themeHint')} value={theme} - onChange={(value) => setTheme(value === 'light' ? 'light' : ('dark' as Theme))} + onChange={(value) => setTheme(value === 'light' ? 'light' : 'dark')} options={[ { value: 'dark', label: t('theme.dark') }, { value: 'light', label: t('theme.light') }, diff --git a/web/src/pages/settings/ProfileSettingsPage.tsx b/web/src/pages/settings/ProfileSettingsPage.tsx index 04e9916..785fcde 100644 --- a/web/src/pages/settings/ProfileSettingsPage.tsx +++ b/web/src/pages/settings/ProfileSettingsPage.tsx @@ -1,44 +1,33 @@ -import { useEffect, useState, type FormEvent } from 'react'; +import { useState, type FormEvent } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users'; +import { userStatuses, type User } from '@/api/types'; import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice'; import { Field, SelectField, TextAreaField } from '@/components/ui/Field'; import { Button, Card } from '@/components/ui/primitives'; import { supportedLanguages, type SupportedLanguage } from '@/i18n'; import { useCurrentUser } from '@/lib/hooks'; import { useSessionStore } from '@/stores/session'; -import { userStatuses } from '@/api/types'; -/** Профиль: отображаемое имя, «о себе», своё состояние, статус и язык. */ -export default function ProfileSettingsPage() { +/** + * Форма профиля. Состояние инициализируется из `user` при монтировании, + * а после сохранения кэш React Query обновляется ответом сервера — поэтому + * синхронизация через эффект не нужна. + */ +function ProfileForm({ user }: { user: User }) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); - const currentUser = useCurrentUser(); - const [displayName, setDisplayName] = useState(''); - const [bio, setBio] = useState(''); - const [customStatus, setCustomStatus] = useState(''); - const [customEmoji, setCustomEmoji] = useState(''); - const [status, setStatus] = useState('online'); - const [locale, setLocale] = useState('ru'); + const [displayName, setDisplayName] = useState(user.display_name); + const [bio, setBio] = useState(user.bio); + const [customStatus, setCustomStatus] = useState(user.custom_status); + const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji); + const [status, setStatus] = useState(user.status); + const [locale, setLocale] = useState(user.locale === 'en' ? 'en' : 'ru'); const [saved, setSaved] = useState(false); - const user = currentUser.data; - - useEffect(() => { - if (user === undefined) { - return; - } - setDisplayName(user.display_name); - setBio(user.bio); - setCustomStatus(user.custom_status); - setCustomEmoji(user.custom_status_emoji); - setStatus(user.status); - setLocale(user.locale === 'en' ? 'en' : 'ru'); - }, [user]); - const save = useMutation({ mutationFn: (input: UpdateProfileInput) => updateProfile(input), onSuccess: (updated) => { @@ -65,22 +54,6 @@ export default function ProfileSettingsPage() { }); }; - if (currentUser.isPending) { - return ( - - - - ); - } - - if (currentUser.isError || user === undefined) { - return ( - - void currentUser.refetch()} /> - - ); - } - return (

{t('settings.profile.title')}

@@ -173,3 +146,34 @@ export default function ProfileSettingsPage() {
); } + +/** Вкладка «Профиль»: ждёт загрузки профиля и передаёт данные форме. */ +export default function ProfileSettingsPage() { + const { t } = useTranslation(); + const currentUser = useCurrentUser(); + + if (currentUser.isPending) { + return ( + + + + ); + } + + if (currentUser.data === undefined) { + return ( + +

{t('common.loading')}

+ {currentUser.isError ? ( + void currentUser.refetch()} + /> + ) : null} +
+ ); + } + + return ; +} diff --git a/web/src/router.tsx b/web/src/router.tsx new file mode 100644 index 0000000..2334a7b --- /dev/null +++ b/web/src/router.tsx @@ -0,0 +1,69 @@ +import { createBrowserRouter, Navigate } from 'react-router'; + +import { AuthGuard } from '@/components/AuthGuard'; +import { + AppearanceSettingsPage, + AppLayout, + IndexRedirect, + GuildView, + InstanceSettingsPage, + LoginPage, + NoGuildView, + OnboardingPage, + ProfileSettingsPage, + RegisterPage, + SecuritySettingsPage, + SettingsLayout, + StatusPage, +} from '@/pages/lazyPages'; + +/** Таблица маршрутов: публичные страницы, защищённые и настройки. */ +export const routes = [ + { + path: '/', + children: [ + { index: true, element: }, + { path: 'login', element: }, + { path: 'register', element: }, + { path: 'status', element: }, + { + element: , + children: [{ path: 'onboarding', element: }], + }, + { + element: , + children: [ + { + path: 'app', + element: , + children: [ + { index: true, element: }, + { path: 'empty', element: }, + { path: ':guildId', element: }, + { path: ':guildId/:channelId', element: }, + ], + }, + ], + }, + { + element: , + children: [ + { + path: 'settings', + element: , + children: [ + { index: true, element: }, + { path: 'profile', element: }, + { path: 'security', element: }, + { path: 'appearance', element: }, + { path: 'instance', element: }, + ], + }, + ], + }, + { path: '*', element: }, + ], + }, +]; + +export const router = createBrowserRouter(routes); diff --git a/web/tests/gateway.test.ts b/web/tests/gateway.test.ts new file mode 100644 index 0000000..a5a9629 --- /dev/null +++ b/web/tests/gateway.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + GatewayConnection, + GatewayOp, + parseGatewaySnapshot, + type GatewayDispatch, + type GatewayStatus, +} from '@/api/gateway'; +import { makeReadySnapshot } from './helpers'; + +/** Подставной WebSocket: рукопожатие и heartbeat полностью под контролем теста. */ +class FakeSocket { + static instances: FakeSocket[] = []; + + readonly url: string; + readyState = 0; + sent: string[] = []; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + + constructor(url: string) { + this.url = url; + FakeSocket.instances.push(this); + } + + open(): void { + this.readyState = 1; + this.onopen?.(new Event('open')); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.readyState = 3; + } + + /** Присылает кадр от сервера. */ + emit(packet: unknown): void { + this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent); + } + + /** Последний отправленный пакет в виде объекта. */ + lastSent(): { op: number; d: unknown } { + const raw = this.sent.at(-1); + if (raw === undefined) { + throw new Error('socket has not sent anything'); + } + return JSON.parse(raw) as { op: number; d: unknown }; + } + + closeFromServer(code = 1006): void { + this.readyState = 3; + this.onclose?.({ code } as CloseEvent); + } +} + +function makeConnection(handlers: { + onDispatch?: (event: GatewayDispatch) => void; + onStatus?: (status: GatewayStatus) => void; + onInvalidSession?: () => void; + onResumeIncomplete?: () => void; +}) { + FakeSocket.instances = []; + const connection = new GatewayConnection({ + url: 'ws://localhost/gateway', + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + handlers, + // Быстрый backoff, чтобы тест переподключения не ждал секунду. + baseBackoffMs: 5, + helloTimeoutMs: 60_000, + }); + connection.connect(); + connections.push(connection); + return connection; +} + +const connections: GatewayConnection[] = []; + +afterEach(() => { + // Закрываем соединения, иначе таймеры heartbeat продолжают жить между тестами. + for (const connection of connections.splice(0)) { + connection.close(); + } + FakeSocket.instances = []; +}); + +describe('gateway client', () => { + it('answers HELLO with IDENTIFY and an empty token', () => { + const connection = makeConnection({}); + const socket = FakeSocket.instances[0]; + expect(socket).toBeDefined(); + socket?.open(); + + socket?.emit({ + op: GatewayOp.HELLO, + d: { heartbeat_interval_ms: 1000, session_id: 'sess-1' }, + }); + + expect(socket?.lastSent()).toEqual({ op: GatewayOp.IDENTIFY, d: { token: '', resume_seq: 0 } }); + expect(connection.getStatus()).toBe('connecting'); + connection.close(); + }); + + it('delivers READY to the dispatch handler', () => { + const events: GatewayDispatch[] = []; + const connection = makeConnection({ onDispatch: (event) => events.push(event) }); + const socket = FakeSocket.instances[0]; + socket?.open(); + socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } }); + socket?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 1, d: makeReadySnapshot([ + { id: 'g-1', name: 'Main', channels: [{ id: 'c-1', name: 'general' }] }, + ]) }); + + expect(events).toHaveLength(1); + expect(events[0]?.t).toBe('READY'); + expect(connection.getStatus()).toBe('connected'); + expect(connection.getLastSeq()).toBe(1); + + const snapshot = parseGatewaySnapshot(events[0]?.d); + expect(snapshot?.guilds[0]?.channels[0]?.name).toBe('general'); + connection.close(); + }); + + it('ignores unknown events and unparsable frames', () => { + const events: GatewayDispatch[] = []; + const connection = makeConnection({ onDispatch: (event) => events.push(event) }); + const socket = FakeSocket.instances[0]; + socket?.open(); + socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } }); + socket?.emit({ op: GatewayOp.DISPATCH, t: 'GUILD_CREATE', s: 2, d: { id: 'g-9' } }); + socket?.emit({ op: 99, d: null }); + socket?.onmessage?.({ data: 'not json' } as MessageEvent); + + expect(events.map((event) => event.t)).toEqual(['GUILD_CREATE']); + connection.close(); + }); + + it('sends HEARTBEAT with the interval from HELLO and tracks the ack', async () => { + const connection = makeConnection({}); + const socket = FakeSocket.instances[0]; + socket?.open(); + socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 20, session_id: 's' } }); + + await new Promise((resolve) => setTimeout(resolve, 45)); + const heartbeat = socket?.lastSent(); + expect(heartbeat).toEqual({ op: GatewayOp.HEARTBEAT, d: null }); + + socket?.emit({ op: GatewayOp.HEARTBEAT_ACK, d: null }); + connection.close(); + }); + + it('reconnects with RESUME and the last sequence number', async () => { + const statuses: GatewayStatus[] = []; + const connection = makeConnection({ onStatus: (status) => statuses.push(status) }); + const first = FakeSocket.instances[0]; + first?.open(); + first?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 'sess-7' } }); + first?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 42, d: makeReadySnapshot([]) }); + + first?.closeFromServer(); + expect(connection.getStatus()).toBe('reconnecting'); + + await new Promise((resolve) => setTimeout(resolve, 200)); + const second = FakeSocket.instances[1]; + expect(second).toBeDefined(); + second?.open(); + second?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 'sess-7' } }); + + expect(second?.lastSent()).toEqual({ op: GatewayOp.RESUME, d: { token: '', resume_seq: 42 } }); + expect(statuses).toContain('reconnecting'); + connection.close(); + }); + + it('reports INVALID_SESSION to the handler', () => { + let invalidated = 0; + const connection = makeConnection({ onInvalidSession: () => (invalidated += 1) }); + const socket = FakeSocket.instances[0]; + socket?.open(); + socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } }); + socket?.emit({ op: GatewayOp.INVALID_SESSION, d: false }); + + expect(invalidated).toBe(1); + expect(connection.getStatus()).toBe('disconnected'); + connection.close(); + }); +}); diff --git a/web/tests/helpers.tsx b/web/tests/helpers.tsx new file mode 100644 index 0000000..853928b --- /dev/null +++ b/web/tests/helpers.tsx @@ -0,0 +1,176 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import { vi } from 'vitest'; + +import { routes } from '@/router'; +import { setQueryClient } from '@/lib/queryClient'; +import { useGatewayStore } from '@/stores/gateway'; +import { useSessionStore } from '@/stores/session'; +import type { User } from '@/api/types'; + +/** Ответ-заглушка с JSON-телом (как у настоящего API). */ +export function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** Ошибка в формате контракта: 4xx + `{"error":{"code","message"}}`. */ +export function apiError(code: string, status = 400, message = 'error'): Response { + return json({ error: { code, message } }, status); +} + +export interface FetchRoute { + /** Подстрока URL, по которой выбирается обработчик. */ + match: string; + method?: string; + response: () => Response | Promise; +} + +/** + * Мок fetch по таблице маршрутов. Метод сравнивается строго (по умолчанию GET), + * URL — по вхождению подстроки. Более длинные шаблоны проверяются раньше, + * поэтому `/users/@me/guilds` не перехватывается записью `/users/@me`. + */ +export function installFetch(routes: FetchRoute[]) { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(); + const ordered = [...routes].sort((left, right) => right.match.length - left.match.length); + for (const route of ordered) { + if ((route.method ?? 'GET').toUpperCase() !== method) { + continue; + } + if (!url.includes(route.match)) { + continue; + } + return Promise.resolve(route.response()); + } + return Promise.resolve(apiError('not_found', 404)); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +/** Мок fetch, который падает на уровне сети (сервер недоступен). */ +export function installFailingFetch() { + const fetchMock = vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); +} + +/** Рендер приложения на заданном маршруте с изолированными сторами. */ +export function renderApp(initialPath = '/') { + const queryClient = createTestQueryClient(); + // Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша), + // поэтому подменяем его на тестовый. + setQueryClient(queryClient); + useSessionStore.getState().reset(); + useGatewayStore.setState({ + status: 'idle', + heartbeatIntervalMs: null, + sessionId: null, + attempts: 0, + invalidated: false, + }); + + const router = createMemoryRouter(routes, { initialEntries: [initialPath] }); + const view = render( + + + , + ); + return { ...view, router, queryClient }; +} + +/** + * Ждёт, пока отрисуется ленивая страница: react-router подгружает её + * асинхронно, поэтому сразу после render DOM ещё пуст. + */ +export async function waitForPage(anchor = 'glchat') { + await waitFor(() => { + expect(document.body.textContent ?? '').toContain(anchor); + }); +} + +/** Ждёт появления поля по label и возвращает его. */ +export async function findField(label: string): Promise { + return screen.findByLabelText(label); +} + +/** Полный профиль пользователя для мока `GET /users/@me`. */ +export function makeUser(overrides: Partial = {}): User { + return { + id: 'user-1', + username: 'alice', + display_name: 'Alice', + bio: '', + status: 'online', + custom_status: '', + custom_status_emoji: '', + is_instance_admin: false, + badges: [], + locale: 'ru', + onboarding_completed: true, + ...overrides, + }; +} + +interface SnapshotGuildInput { + id: string; + name: string; + is_main?: boolean; + channels?: { + id: string; + name: string; + type?: 'text' | 'voice' | 'category'; + position?: number; + parent_id?: string; + can_send?: boolean; + can_view?: boolean; + }[]; +} + +/** Снапшот READY в формате шлюза. */ +export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) { + return { + user: { + id: 'user-1', + username: 'alice', + display_name: 'Alice', + is_instance_admin: false, + badges: [], + }, + guilds: guilds.map((guild) => ({ + id: guild.id, + name: guild.name, + owner_id: 'user-1', + is_main: guild.is_main ?? false, + channels: (guild.channels ?? []).map((channel, index) => ({ + id: channel.id, + name: channel.name, + type: channel.type ?? 'text', + position: channel.position ?? index, + ...(channel.parent_id === undefined ? {} : { parent_id: channel.parent_id }), + ...(channel.can_send === undefined ? {} : { can_send: channel.can_send }), + ...(channel.can_view === undefined ? {} : { can_view: channel.can_view }), + })), + roles: [], + member_ids: ['user-1'], + my_role_ids: [], + my_permissions: [], + })), + dm_channels: [], + read_states: [], + session_id: 'session-1', + heartbeat_interval_ms: 45_000, + }; +} diff --git a/web/tests/login.test.tsx b/web/tests/login.test.tsx new file mode 100644 index 0000000..ffc1234 --- /dev/null +++ b/web/tests/login.test.tsx @@ -0,0 +1,100 @@ +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'; + +const instanceOpen = { + name: 'glchat-test', + version: 'v0.1.0-test', + registration_enabled: true, + allow_guild_creation: true, + max_guilds_per_user: 5, + max_members_per_guild: 100, + max_message_length: 2000, + main_guild_id: 'g-main', + user_count: 1, + guild_count: 1, +}; + +describe('страница входа', () => { + it('успешный вход переводит в /app', async () => { + const user = makeUser(); + const fetchMock = installFetch([ + { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, + { match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + { match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) }, + ]); + + const { router } = renderApp('/login'); + const visitor = userEvent.setup(); + + await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com'); + await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1'); + await visitor.click(screen.getByRole('button', { name: 'Войти' })); + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app/empty'); + }); + expect( + 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({ + email: 'alice@example.com', + password: 'super-secret-1', + }); + }); + + it('при auth.2fa_required показывает поле кода и повторяет вход с totp_code', async () => { + const user = makeUser(); + let loginAttempts = 0; + const fetchMock = installFetch([ + { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, + { + match: '/api/v1/auth/login', + method: 'POST', + response: () => { + loginAttempts += 1; + return loginAttempts === 1 ? apiError('auth.2fa_required', 401) : json({ user }); + }, + }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + ]); + + const { router } = renderApp('/login'); + const visitor = userEvent.setup(); + + await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com'); + await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1'); + await visitor.click(screen.getByRole('button', { name: 'Войти' })); + + const totpField = await screen.findByLabelText('Код 2FA'); + expect(totpField).toBeVisible(); + + await visitor.type(totpField, '123456'); + await visitor.click(screen.getByRole('button', { name: 'Войти' })); + + await waitFor(() => { + 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({ + 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 new file mode 100644 index 0000000..3fe2109 --- /dev/null +++ b/web/tests/register.test.tsx @@ -0,0 +1,96 @@ +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'; + +const instanceBase = { + name: 'glchat-test', + version: 'v0.1.0-test', + allow_guild_creation: true, + max_guilds_per_user: 5, + max_members_per_guild: 100, + max_message_length: 2000, + user_count: 1, + guild_count: 0, +}; + +describe('страница регистрации', () => { + it('успешная регистрация ведёт на /onboarding', async () => { + const user = makeUser({ onboarding_completed: false }); + const fetchMock = installFetch([ + { + match: '/api/v1/instance', + response: () => json({ instance: { ...instanceBase, registration_enabled: true } }), + }, + { match: '/api/v1/auth/register', method: 'POST', response: () => json({ user }) }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + ]); + + const { router } = renderApp('/register'); + const visitor = userEvent.setup(); + + await visitor.type(await screen.findByLabelText('Имя пользователя'), 'alice'); + await visitor.type(screen.getByLabelText('Отображаемое имя'), 'Алиса'); + await visitor.type(screen.getByLabelText('Почта'), 'alice@example.com'); + await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1'); + await visitor.click(screen.getByRole('button', { name: 'Создать аккаунт' })); + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/onboarding'); + }); + 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({ + username: 'alice', + display_name: 'Алиса', + email: 'alice@example.com', + password: 'super-secret-1', + locale: 'ru', + }); + }); + + it('не отправляет форму с некорректными данными', async () => { + const fetchMock = installFetch([ + { + match: '/api/v1/instance', + response: () => json({ instance: { ...instanceBase, registration_enabled: true } }), + }, + ]); + + renderApp('/register'); + const visitor = userEvent.setup(); + + await visitor.type(await screen.findByLabelText('Имя пользователя'), 'al'); + await visitor.type(screen.getByLabelText('Отображаемое имя'), 'Алиса'); + await visitor.type(screen.getByLabelText('Почта'), 'not-an-email'); + await visitor.type(screen.getByLabelText('Пароль'), 'short'); + await visitor.click(screen.getByRole('button', { name: 'Создать аккаунт' })); + + expect(await screen.findByText(/Имя пользователя: 3–32 символа/u)).toBeVisible(); + expect(screen.getByText('Укажите корректный адрес почты.')).toBeVisible(); + expect(screen.getByText('Пароль должен быть не короче 8 символов.')).toBeVisible(); + expect( + fetchMock.mock.calls.filter(([input]) => String(input).includes('/auth/register')), + ).toHaveLength(0); + }); + + it('при registration_enabled=false показывает сообщение вместо формы', async () => { + installFetch([ + { + match: '/api/v1/instance', + response: () => json({ instance: { ...instanceBase, registration_enabled: false } }), + }, + ]); + + renderApp('/register'); + + expect(await screen.findByText('Регистрация закрыта')).toBeVisible(); + expect(screen.queryByLabelText('Имя пользователя')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Создать аккаунт' })).toBeNull(); + }); +}); diff --git a/web/tests/session.test.ts b/web/tests/session.test.ts new file mode 100644 index 0000000..8276740 --- /dev/null +++ b/web/tests/session.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { act } from '@testing-library/react'; + +import { parseGatewaySnapshot } from '@/api/gateway'; +import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway'; +import { useSessionStore } from '@/stores/session'; +import { makeReadySnapshot } from './helpers'; + +afterEach(() => { + useSessionStore.getState().reset(); + useGatewayStore.setState({ + status: 'idle', + heartbeatIntervalMs: null, + sessionId: null, + attempts: 0, + invalidated: false, + }); +}); + +describe('session store', () => { + it('applies a READY snapshot: guilds, channels and session id', () => { + const snapshot = parseGatewaySnapshot( + makeReadySnapshot([ + { + id: 'g-1', + name: 'Main', + is_main: true, + channels: [ + { id: 'c-1', name: 'general', position: 0 }, + { id: 'c-2', name: 'voice', type: 'voice', position: 1 }, + ], + }, + ]), + ); + expect(snapshot).not.toBeNull(); + + act(() => { + dispatchGatewayEvent({ op: 0, t: 'READY', s: 1, d: makeReadySnapshot([ + { + id: 'g-1', + name: 'Main', + is_main: true, + channels: [ + { id: 'c-1', name: 'general', position: 0 }, + { id: 'c-2', name: 'voice', type: 'voice', position: 1 }, + ], + }, + ]) }); + }); + + const state = useSessionStore.getState(); + expect(state.guilds).toHaveLength(1); + expect(state.guilds[0]?.name).toBe('Main'); + expect(state.guilds[0]?.channels.map((channel) => channel.id)).toEqual(['c-1', 'c-2']); + expect(state.sessionId).toBe('session-1'); + expect(state.user?.display_name).toBe('Alice'); + expect(useGatewayStore.getState().status).toBe('connected'); + expect(useGatewayStore.getState().heartbeatIntervalMs).toBe(45_000); + }); + + it('keeps channels with can_send=false (only can_view=false is hidden)', () => { + act(() => { + dispatchGatewayEvent({ + op: 0, + t: 'READY', + s: 2, + d: makeReadySnapshot([ + { + id: 'g-1', + name: 'Main', + channels: [ + { id: 'c-readonly', name: 'announcements', can_send: false }, + { id: 'c-hidden', name: 'secret', can_view: false }, + ], + }, + ]), + }); + }); + + const channels = useSessionStore.getState().guilds[0]?.channels ?? []; + expect(channels.map((channel) => channel.id)).toEqual(['c-readonly', 'c-hidden']); + }); + + it('ignores unknown events and malformed snapshots', () => { + act(() => { + dispatchGatewayEvent({ op: 0, t: 'SOMETHING_NEW', s: 3, d: { anything: true } }); + dispatchGatewayEvent({ op: 0, t: 'READY', s: 4, d: { guilds: [] } }); + }); + + expect(useSessionStore.getState().guilds).toEqual([]); + expect(useSessionStore.getState().user).toBeNull(); + }); + + it('drops a guild and clears the selection when leaving', () => { + act(() => { + dispatchGatewayEvent({ + op: 0, + t: 'READY', + s: 5, + d: makeReadySnapshot([{ id: 'g-1', name: 'Main' }]), + }); + useSessionStore.getState().selectChannel('g-1', null); + useSessionStore.getState().removeGuild('g-1'); + }); + + const state = useSessionStore.getState(); + expect(state.guilds).toEqual([]); + expect(state.selectedGuildId).toBeNull(); + }); +}); diff --git a/web/tests/setup.ts b/web/tests/setup.ts index a95ccc3..366da3c 100644 --- a/web/tests/setup.ts +++ b/web/tests/setup.ts @@ -2,6 +2,10 @@ import '@testing-library/jest-dom/vitest'; import { afterEach, vi } from 'vitest'; import { cleanup } from '@testing-library/react'; +// Общий экземпляр i18next для всех тестов: без него useTranslation вернёт ключи. +import '@/i18n'; +import { setQueryClient } from '@/lib/queryClient'; + afterEach(() => { cleanup(); // Globals stubbed with vi.stubGlobal must be dropped between tests, @@ -9,4 +13,5 @@ afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); window.localStorage.clear(); + setQueryClient(null); });