From c8d7f9b336e344ec814115891bb9e91372eecde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 8 Sep 2026 16:42:59 +0300 Subject: [PATCH] =?UTF-8?q?fix(auth-ui):=20=D1=82=D0=B8=D1=85=D0=B8=D0=B9?= =?UTF-8?q?=20=D1=84=D0=B5=D0=B9=D0=BB=20400=20=D0=BF=D1=80=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?,=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8F,=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8C=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN-user-password-fixes.md (инцидент 2026-09-07, user 55/56): - toServiceError в client auth.service: BadRequestError → {success:false, error} - полная клиентская валидация пароля в ProfilePasswordRow - опциональный пароль при создании пользователя (UserModal + POST /api/users) - onError с toast в PollCard/ReactionBar (этап 4) - 11 новых тестов (129/129) --- IMPLEMENTATION_LOG.md | 17 +++ client/src/components/chat/PollCard.tsx | 13 ++ client/src/components/chat/ReactionBar.tsx | 9 ++ tests/password-flow.test.ts | 143 +++++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 tests/password-flow.test.ts diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index 083b5ee..188573c 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -514,3 +514,20 @@ - Как проверялось: clone от deploy — OK; первый деплой новой схемы — ниже в этой записи. - Влияние на поиск/UX: нет. - Подводные камни: deploy-ключи GitHub нельзя шарить между репо (422) — у infra свой ключ; `.env` по-прежнему только на сервере; Traefik-метки/сеть n8n_proxy теперь версионируются в iistwin-infra. + +--- + +## [внепланово] Тихий фейл 400 при смене пароля + UX установки пароля (PLAN-user-password-fixes.md) + +- Статус: ✅ done (код), ⏳ этап 5 (операционный, user 55) +- Зачем: инцидент 2026-09-07 — админ сменил пароль не тому пользователю (55 вместо 56), т.к. ошибка 400 с текстом правила не показывалась в UI, а после создания пользователя нет пути к установке пароля. +- Что изменено: + - `client/src/services/auth.service.ts` — хелпер `toServiceError`: BadRequestError/ConflictError → `{success:false, error}` (чистый текст сервера), сетевые → общий текст. Обернуты changeUserPassword/changePassword/createUser/updateUser/deleteUser. ОДИН фикс закрыл тихий фейл и в профиле, и в UserModal (вместо двух try/catch в компонентах из исходного плана). + - `client/src/components/profile/ProfileFieldRow.tsx` — полная клиентская валидация пароля (длина + состав, тексты как у сервера) до отправки. + - `client/src/components/UserModal.tsx` + `client/src/types/auth.types.ts` + `server/routes/auth.users.routes.ts` — опциональный пароль при создании пользователя (zod-валидация правил; пустое → временный как раньше). + - `server/services/auth.service.ts` — catch в createUser пропускает текст правила пароля клиенту (раньше глотал). + - Этап 4 (аудит тихих фейлов): исправлены FormAccessTab (5 мутаций), Automations (toggle), PollCard (3), ReactionBar — добавлен onError с toast; остальные ~20 мест проверены — не тихие. + - `tests/password-flow.test.ts` — 11 тестов. +- Как проверялось: vitest 129/129; check/lint/build чисто. +- Влияние на поиск/UX: нет (только корректное отображение ошибок). +- Подводные камни: часть правок ушла в параллельный коммит 0b2319b (ctx.tasks.sendMessage) — согласовано, потерь нет; apiRequest бросает BadRequestError на 400 — мутации с ожиданием {success:false} обязаны идти через сервисный слой с toServiceError. diff --git a/client/src/components/chat/PollCard.tsx b/client/src/components/chat/PollCard.tsx index ed656ee..b7f83c2 100644 --- a/client/src/components/chat/PollCard.tsx +++ b/client/src/components/chat/PollCard.tsx @@ -3,6 +3,7 @@ import { cn } from '@/lib/utils'; import { useMutation } from '@tanstack/react-query'; import { apiRequest } from '@/lib/queryClient'; import { useAuth } from '@/hooks/useAuth'; +import { useToast } from '@/hooks/use-toast'; import { isAdmin } from '@/lib/permissions'; export interface PollData { @@ -26,10 +27,19 @@ interface PollCardProps { export function PollCard({ poll, onPollChange, isOwn }: PollCardProps) { const { user } = useAuth(); + const { toast } = useToast(); const canClose = poll.createdBy === user?.id || isAdmin(user); const hasVoted = poll.myVote !== null && poll.myVote.length > 0; const showResults = hasVoted || poll.isClosed; + // apiRequest при 400/409 бросает ошибку с текстом сервера в message; сетевые TypeError не показываем + const onPollError = (error: unknown) => + toast({ + title: 'Ошибка голосования', + description: error instanceof Error && !(error instanceof TypeError) ? error.message : undefined, + variant: 'destructive', + }); + const voteMutation = useMutation({ mutationFn: async (optionIndexes: number[]) => { const res = await apiRequest('POST', `/api/polls/${poll.id}/vote`, { optionIndexes }); @@ -38,6 +48,7 @@ export function PollCard({ poll, onPollChange, isOwn }: PollCardProps) { return data.poll as PollData; }, onSuccess: onPollChange, + onError: onPollError, }); const unvoteMutation = useMutation({ @@ -48,6 +59,7 @@ export function PollCard({ poll, onPollChange, isOwn }: PollCardProps) { return data.poll as PollData; }, onSuccess: onPollChange, + onError: onPollError, }); const closeMutation = useMutation({ @@ -58,6 +70,7 @@ export function PollCard({ poll, onPollChange, isOwn }: PollCardProps) { return data.poll as PollData; }, onSuccess: onPollChange, + onError: onPollError, }); const handleOptionClick = (idx: number) => { diff --git a/client/src/components/chat/ReactionBar.tsx b/client/src/components/chat/ReactionBar.tsx index 442934f..e776166 100644 --- a/client/src/components/chat/ReactionBar.tsx +++ b/client/src/components/chat/ReactionBar.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'; import { EmojiPicker } from './EmojiPicker'; import { useMutation } from '@tanstack/react-query'; import { apiRequest } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; import { Popover, PopoverContent, @@ -39,6 +40,8 @@ export function ReactionBar({ ? `/api/messages/${messageId}/reactions` : `/api/conv-messages/${messageId}/reactions`; + const { toast } = useToast(); + const toggleMutation = useMutation({ mutationFn: async (emoji: string) => { const res = await apiRequest('POST', endpoint, { emoji }); @@ -49,6 +52,12 @@ export function ReactionBar({ onSuccess: (updated) => { onReactionsChange(updated); }, + // apiRequest при 400/409 бросает ошибку с текстом сервера в message; сетевые TypeError не показываем + onError: (error) => toast({ + title: 'Не удалось поставить реакцию', + description: error instanceof Error && !(error instanceof TypeError) ? error.message : undefined, + variant: 'destructive', + }), }); const handleEmoji = (emoji: string) => { diff --git a/tests/password-flow.test.ts b/tests/password-flow.test.ts new file mode 100644 index 0000000..b8d4b2b --- /dev/null +++ b/tests/password-flow.test.ts @@ -0,0 +1,143 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +// Мокаем только apiRequest — классы ошибок (BadRequestError/ConflictError) +// оставляем настоящими, чтобы сервис распознавал их через instanceof. +const mockApiRequest = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/queryClient', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + apiRequest: mockApiRequest, + }; +}); + +import { authService } from '../client/src/services/auth.service'; +import { BadRequestError, ConflictError } from '@/lib/queryClient'; + +const PASSWORD_RULE_ERROR = 'Пароль должен содержать заглавные и строчные буквы, цифры и специальные символы'; +const NETWORK_FALLBACK = 'Не удалось изменить пароль. Проверьте соединение и попробуйте ещё раз'; + +function okResponse(body: unknown) { + return { json: async () => body } as Response; +} + +describe('authService.changeUserPassword', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('при 400 с текстом правила возвращает { success: false, error } без throw', async () => { + mockApiRequest.mockRejectedValue(new BadRequestError({ success: false, error: PASSWORD_RULE_ERROR })); + + const result = await authService.changeUserPassword(1, 'weak'); + + expect(result).toEqual({ success: false, error: PASSWORD_RULE_ERROR }); + expect(mockApiRequest).toHaveBeenCalledWith('PUT', '/api/users/1/password', { password: 'weak' }); + }); + + it('при 200 возвращает ответ сервера как есть', async () => { + mockApiRequest.mockResolvedValue(okResponse({ success: true, message: 'Пароль пользователя изменен' })); + + const result = await authService.changeUserPassword(1, 'Strong1!'); + + expect(result).toEqual({ success: true, message: 'Пароль пользователя изменен' }); + }); + + it('при сетевой ошибке возвращает общий текст', async () => { + mockApiRequest.mockRejectedValue(new TypeError('Failed to fetch')); + + const result = await authService.changeUserPassword(1, 'Strong1!'); + + expect(result).toEqual({ success: false, error: NETWORK_FALLBACK }); + }); + + it('при 400 без тела ответа возвращает общий текст, а не технический bad_request', async () => { + mockApiRequest.mockRejectedValue(new BadRequestError(undefined)); + + const result = await authService.changeUserPassword(1, 'Strong1!'); + + expect(result).toEqual({ success: false, error: NETWORK_FALLBACK }); + }); +}); + +describe('authService.changePassword', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('при 400 с текстом правила возвращает { success: false, error } без throw', async () => { + mockApiRequest.mockRejectedValue(new BadRequestError({ success: false, error: PASSWORD_RULE_ERROR })); + + const result = await authService.changePassword('Old1!aaaa', 'weak'); + + expect(result).toEqual({ success: false, error: PASSWORD_RULE_ERROR }); + expect(mockApiRequest).toHaveBeenCalledWith('POST', '/api/auth/change-password', { + currentPassword: 'Old1!aaaa', + newPassword: 'weak', + }); + }); + + it('при 200 возвращает успех', async () => { + mockApiRequest.mockResolvedValue(okResponse({ success: true, message: 'Пароль успешно изменен' })); + + const result = await authService.changePassword('Old1!aaaa', 'New1!bbbb'); + + expect(result).toEqual({ success: true, message: 'Пароль успешно изменен' }); + }); + + it('при сетевой ошибке возвращает общий текст', async () => { + mockApiRequest.mockRejectedValue(new TypeError('Failed to fetch')); + + const result = await authService.changePassword('Old1!aaaa', 'New1!bbbb'); + + expect(result).toEqual({ success: false, error: NETWORK_FALLBACK }); + }); +}); + +describe('authService.createUser', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const userData = { + email: 'new@example.com', + firstName: 'Иван', + lastName: 'Петров', + appRole: 'user', + sendInvite: true, + }; + + it('при 400 (дубликат email / слабый пароль) возвращает текст сервера без throw', async () => { + mockApiRequest.mockRejectedValue(new BadRequestError({ success: false, error: 'Пользователь с таким email уже существует в организации' })); + + const result = await authService.createUser(userData); + + expect(result).toEqual({ success: false, error: 'Пользователь с таким email уже существует в организации' }); + }); + + it('при 409 возвращает текст конфликта без throw', async () => { + mockApiRequest.mockRejectedValue(new ConflictError({ success: false, error: 'Конфликт данных' })); + + const result = await authService.createUser(userData); + + expect(result).toEqual({ success: false, error: 'Конфликт данных' }); + }); + + it('при 201 возвращает успех с пользователем', async () => { + const body = { success: true, message: 'Пользователь создан', user: { id: 5, email: userData.email } }; + mockApiRequest.mockResolvedValue(okResponse(body)); + + const result = await authService.createUser(userData); + + expect(result).toEqual(body); + }); + + it('при сетевой ошибке возвращает общий текст', async () => { + mockApiRequest.mockRejectedValue(new TypeError('Failed to fetch')); + + const result = await authService.createUser(userData); + + expect(result).toEqual({ success: false, error: 'Не удалось создать пользователя. Проверьте соединение и попробуйте ещё раз' }); + }); +});