fix(auth-ui): тихий фейл 400 при смене пароля, валидация, пароль при создании
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)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
143
tests/password-flow.test.ts
Normal file
143
tests/password-flow.test.ts
Normal file
@@ -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<typeof import('@/lib/queryClient')>();
|
||||
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: 'Не удалось создать пользователя. Проверьте соединение и попробуйте ещё раз' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user