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; первый деплой новой схемы — ниже в этой записи.
|
- Как проверялось: clone от deploy — OK; первый деплой новой схемы — ниже в этой записи.
|
||||||
- Влияние на поиск/UX: нет.
|
- Влияние на поиск/UX: нет.
|
||||||
- Подводные камни: deploy-ключи GitHub нельзя шарить между репо (422) — у infra свой ключ; `.env` по-прежнему только на сервере; Traefik-метки/сеть n8n_proxy теперь версионируются в iistwin-infra.
|
- Подводные камни: 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 { useMutation } from '@tanstack/react-query';
|
||||||
import { apiRequest } from '@/lib/queryClient';
|
import { apiRequest } from '@/lib/queryClient';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { isAdmin } from '@/lib/permissions';
|
import { isAdmin } from '@/lib/permissions';
|
||||||
|
|
||||||
export interface PollData {
|
export interface PollData {
|
||||||
@@ -26,10 +27,19 @@ interface PollCardProps {
|
|||||||
|
|
||||||
export function PollCard({ poll, onPollChange, isOwn }: PollCardProps) {
|
export function PollCard({ poll, onPollChange, isOwn }: PollCardProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
const canClose = poll.createdBy === user?.id || isAdmin(user);
|
const canClose = poll.createdBy === user?.id || isAdmin(user);
|
||||||
const hasVoted = poll.myVote !== null && poll.myVote.length > 0;
|
const hasVoted = poll.myVote !== null && poll.myVote.length > 0;
|
||||||
const showResults = hasVoted || poll.isClosed;
|
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({
|
const voteMutation = useMutation({
|
||||||
mutationFn: async (optionIndexes: number[]) => {
|
mutationFn: async (optionIndexes: number[]) => {
|
||||||
const res = await apiRequest('POST', `/api/polls/${poll.id}/vote`, { optionIndexes });
|
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;
|
return data.poll as PollData;
|
||||||
},
|
},
|
||||||
onSuccess: onPollChange,
|
onSuccess: onPollChange,
|
||||||
|
onError: onPollError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const unvoteMutation = useMutation({
|
const unvoteMutation = useMutation({
|
||||||
@@ -48,6 +59,7 @@ export function PollCard({ poll, onPollChange, isOwn }: PollCardProps) {
|
|||||||
return data.poll as PollData;
|
return data.poll as PollData;
|
||||||
},
|
},
|
||||||
onSuccess: onPollChange,
|
onSuccess: onPollChange,
|
||||||
|
onError: onPollError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeMutation = useMutation({
|
const closeMutation = useMutation({
|
||||||
@@ -58,6 +70,7 @@ export function PollCard({ poll, onPollChange, isOwn }: PollCardProps) {
|
|||||||
return data.poll as PollData;
|
return data.poll as PollData;
|
||||||
},
|
},
|
||||||
onSuccess: onPollChange,
|
onSuccess: onPollChange,
|
||||||
|
onError: onPollError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOptionClick = (idx: number) => {
|
const handleOptionClick = (idx: number) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { EmojiPicker } from './EmojiPicker';
|
import { EmojiPicker } from './EmojiPicker';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { apiRequest } from '@/lib/queryClient';
|
import { apiRequest } from '@/lib/queryClient';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -39,6 +40,8 @@ export function ReactionBar({
|
|||||||
? `/api/messages/${messageId}/reactions`
|
? `/api/messages/${messageId}/reactions`
|
||||||
: `/api/conv-messages/${messageId}/reactions`;
|
: `/api/conv-messages/${messageId}/reactions`;
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: async (emoji: string) => {
|
mutationFn: async (emoji: string) => {
|
||||||
const res = await apiRequest('POST', endpoint, { emoji });
|
const res = await apiRequest('POST', endpoint, { emoji });
|
||||||
@@ -49,6 +52,12 @@ export function ReactionBar({
|
|||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
onReactionsChange(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) => {
|
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