From 1fe8b7fb1d8c28f67ac86e05ad9de7e8e80e0692 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Wed, 20 May 2026 00:59:09 -0700 Subject: [PATCH] fix(auth): use validity-returned token in ChangePasswordForm reset submit (#36415) --- .../ChangePasswordForm.spec.tsx | 86 +++++++++++++++++++ .../forgot-password/ChangePasswordForm.tsx | 6 +- web/service/use-common.ts | 2 +- 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 web/app/forgot-password/ChangePasswordForm.spec.tsx diff --git a/web/app/forgot-password/ChangePasswordForm.spec.tsx b/web/app/forgot-password/ChangePasswordForm.spec.tsx new file mode 100644 index 0000000000..b69ca1bf1e --- /dev/null +++ b/web/app/forgot-password/ChangePasswordForm.spec.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { changePasswordWithToken } from '@/service/common' +import { useVerifyForgotPasswordToken } from '@/service/use-common' +import ChangePasswordForm from './ChangePasswordForm' + +const mockReplace = vi.fn() +vi.mock('@/next/navigation', () => ({ + useSearchParams: () => new URLSearchParams('token=url-token-t1'), + useRouter: () => ({ replace: mockReplace }), +})) + +vi.mock('@/service/use-common', () => ({ + useVerifyForgotPasswordToken: vi.fn(), +})) + +vi.mock('@/service/common', () => ({ + changePasswordWithToken: vi.fn(), +})) + +vi.mock('@/utils/var', () => ({ basePath: '' })) + +type UseVerifyResult = ReturnType +const mockUseVerify = vi.mocked(useVerifyForgotPasswordToken) +const mockChangePassword = vi.mocked(changePasswordWithToken) + +const VALID_PASSWORD = 'ValidPass123!' + +describe('ChangePasswordForm', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('when token is valid', () => { + const T2 = 'verified-token-t2' + + beforeEach(() => { + mockUseVerify.mockReturnValue({ + data: { result: 'success', is_valid: true, email: 'user@example.com', token: T2 }, + refetch: vi.fn(), + } as unknown as UseVerifyResult) + }) + + it('renders the password form', () => { + render() + expect(screen.getByText('login.changePassword')).toBeInTheDocument() + }) + + it('submits with T2 (from validity response), NOT T1 (from URL)', async () => { + mockChangePassword.mockResolvedValue({ result: 'success' }) + + render() + + const inputs = Array.from(document.querySelectorAll('input[type="password"]')) as [HTMLInputElement, HTMLInputElement] + fireEvent.change(inputs[0], { target: { value: VALID_PASSWORD } }) + fireEvent.change(inputs[1], { target: { value: VALID_PASSWORD } }) + + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.reset/ })) + + await waitFor(() => { + expect(mockChangePassword).toHaveBeenCalledWith({ + url: '/forgot-password/resets', + body: { + token: T2, + new_password: VALID_PASSWORD, + password_confirm: VALID_PASSWORD, + }, + }) + }) + }) + }) + + describe('when token is invalid', () => { + beforeEach(() => { + mockUseVerify.mockReturnValue({ + data: { result: 'success', is_valid: false, email: '', token: '' }, + refetch: vi.fn(), + } as unknown as UseVerifyResult) + }) + + it('shows invalid token state and no form', () => { + render() + expect(screen.getByText('login.invalid')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /common\.operation\.reset/ })).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/forgot-password/ChangePasswordForm.tsx b/web/app/forgot-password/ChangePasswordForm.tsx index deb520c436..72baba2553 100644 --- a/web/app/forgot-password/ChangePasswordForm.tsx +++ b/web/app/forgot-password/ChangePasswordForm.tsx @@ -49,7 +49,7 @@ const ChangePasswordForm = () => { }, [password, confirmPassword, showErrorMessage, t]) const handleChangePassword = useCallback(async () => { - const token = searchParams.get('token') || '' + const resetToken = verifyTokenRes?.token ?? '' if (!valid()) return @@ -57,7 +57,7 @@ const ChangePasswordForm = () => { await changePasswordWithToken({ url: '/forgot-password/resets', body: { - token, + token: resetToken, new_password: password, password_confirm: confirmPassword, }, @@ -67,7 +67,7 @@ const ChangePasswordForm = () => { catch { await revalidateToken() } - }, [confirmPassword, password, revalidateToken, searchParams, valid]) + }, [confirmPassword, password, revalidateToken, verifyTokenRes?.token, valid]) return (
{ }) } -type ForgotPasswordValidity = CommonResponse & { is_valid: boolean, email: string } +type ForgotPasswordValidity = CommonResponse & { is_valid: boolean, email: string, token: string } export const useVerifyForgotPasswordToken = (token?: string | null) => { return useQuery({ queryKey: commonQueryKeys.forgotPasswordValidity(token),