From 7c71875da3a12b45c905704b8e07478edcda0a7d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 2 Mar 2026 22:20:00 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Restore=20Post-Auth=20Nav?= =?UTF-8?q?igation=20After=20Silent=20Token=20Refresh=20(#12025)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Update import path for StartupLayout in tests * ๐Ÿ”’ fix: Enhance AuthContext to handle stored redirects during user authentication - Added SESSION_KEY import and logic to retrieve and clear stored redirect URLs from sessionStorage. - Updated user context state to include redirect URL, defaulting to '/c/new' if none is found. * ๐Ÿงช test: Add tests for silentRefresh post-login redirect handling in AuthContext - Introduced new test suite to validate navigation behavior after successful token refresh. - Implemented tests for stored sessionStorage redirects, default navigation, and prevention of unsafe redirects. - Enhanced logout error handling tests to ensure proper state clearing without external redirects. * ๐Ÿ”’ fix: Update AuthContext to handle unsafe stored redirects during authentication - Removed conditional check for stored redirect in sessionStorage, ensuring it is always cleared. - Enhanced logic to validate stored redirects, defaulting to '/c/new' for unsafe URLs. - Updated tests to verify navigation behavior for both safe and unsafe redirects after token refresh. --- client/src/hooks/AuthContext.tsx | 11 +- .../src/hooks/__tests__/AuthContext.spec.tsx | 138 ++++++++++++++++++ .../routes/__tests__/StartupLayout.spec.tsx | 2 +- 3 files changed, 148 insertions(+), 3 deletions(-) diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index ca82e10f8f..a0613f113c 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -20,7 +20,7 @@ import { useLogoutUserMutation, useRefreshTokenMutation, } from '~/data-provider'; -import { isSafeRedirect, buildLoginRedirectUrl, getPostLoginRedirect } from '~/utils'; +import { SESSION_KEY, isSafeRedirect, buildLoginRedirectUrl, getPostLoginRedirect } from '~/utils'; import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common'; import useTimeout from './useTimeout'; import store from '~/store'; @@ -166,7 +166,14 @@ const AuthContextProvider = ({ } const { user, token = '' } = data ?? {}; if (token) { - setUserContext({ token, isAuthenticated: true, user }); + const storedRedirect = sessionStorage.getItem(SESSION_KEY); + sessionStorage.removeItem(SESSION_KEY); + setUserContext({ + user, + token, + isAuthenticated: true, + redirect: storedRedirect && isSafeRedirect(storedRedirect) ? storedRedirect : '/c/new', + }); return; } console.log('Token is not present. User is not authenticated.'); diff --git a/client/src/hooks/__tests__/AuthContext.spec.tsx b/client/src/hooks/__tests__/AuthContext.spec.tsx index 20af37e3f2..4819f0f6d4 100644 --- a/client/src/hooks/__tests__/AuthContext.spec.tsx +++ b/client/src/hooks/__tests__/AuthContext.spec.tsx @@ -7,6 +7,7 @@ import { MemoryRouter } from 'react-router-dom'; import type { TAuthConfig } from '~/common'; import { AuthContextProvider, useAuthContext } from '../AuthContext'; +import { SESSION_KEY } from '~/utils'; const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ @@ -274,6 +275,143 @@ describe('AuthContextProvider โ€” logout onSuccess/onError handling', () => { expect(window.location.replace).toHaveBeenCalled(); expect(mockRefreshMutate).not.toHaveBeenCalled(); }); +}); + +describe('AuthContextProvider โ€” silentRefresh post-login redirect', () => { + beforeEach(() => { + jest.clearAllMocks(); + sessionStorage.clear(); + }); + + afterEach(() => { + sessionStorage.clear(); + }); + + it('navigates to stored sessionStorage redirect after successful token refresh', () => { + jest.useFakeTimers(); + sessionStorage.setItem(SESSION_KEY, '/c/new?endpoint=bedrock&model=claude-sonnet-4-6'); + + renderProviderLive(); + + expect(mockRefreshMutate).toHaveBeenCalledTimes(1); + const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [ + unknown, + { onSuccess: (data: unknown) => void }, + ]; + + act(() => { + refreshOptions.onSuccess({ user: { id: '1', role: 'USER' }, token: 'new-token' }); + }); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/c/new?endpoint=bedrock&model=claude-sonnet-4-6', { + replace: true, + }); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + jest.useRealTimers(); + }); + + it('navigates to /c/new when no stored redirect exists', () => { + jest.useFakeTimers(); + + renderProviderLive(); + + expect(mockRefreshMutate).toHaveBeenCalledTimes(1); + const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [ + unknown, + { onSuccess: (data: unknown) => void }, + ]; + + act(() => { + refreshOptions.onSuccess({ user: { id: '1', role: 'USER' }, token: 'new-token' }); + }); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/c/new', { replace: true }); + jest.useRealTimers(); + }); + + it('does not re-trigger silentRefresh after successful redirect', () => { + jest.useFakeTimers(); + sessionStorage.setItem(SESSION_KEY, '/c/abc?endpoint=bedrock'); + + renderProviderLive(); + + expect(mockRefreshMutate).toHaveBeenCalledTimes(1); + const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [ + unknown, + { onSuccess: (data: unknown) => void }, + ]; + mockRefreshMutate.mockClear(); + + act(() => { + refreshOptions.onSuccess({ user: { id: '1', role: 'USER' }, token: 'new-token' }); + }); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith('/c/abc?endpoint=bedrock', { replace: true }); + expect(mockRefreshMutate).not.toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('falls back to /c/new for unsafe stored redirect', () => { + jest.useFakeTimers(); + sessionStorage.setItem(SESSION_KEY, 'https://evil.com/steal'); + + renderProviderLive(); + + expect(mockRefreshMutate).toHaveBeenCalledTimes(1); + const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [ + unknown, + { onSuccess: (data: unknown) => void }, + ]; + + act(() => { + refreshOptions.onSuccess({ user: { id: '1', role: 'USER' }, token: 'new-token' }); + }); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/c/new', { replace: true }); + expect(mockNavigate).not.toHaveBeenCalledWith('https://evil.com/steal', expect.anything()); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + jest.useRealTimers(); + }); +}); + +describe('AuthContextProvider โ€” logout error handling', () => { + const originalLocation = window.location; + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + pathname: '/c/some-chat', + search: '', + hash: '', + replace: jest.fn(), + }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + }); it('clears auth state on logout error without external redirect', () => { jest.useFakeTimers(); diff --git a/client/src/routes/__tests__/StartupLayout.spec.tsx b/client/src/routes/__tests__/StartupLayout.spec.tsx index 8d2c183137..3e64d19cf2 100644 --- a/client/src/routes/__tests__/StartupLayout.spec.tsx +++ b/client/src/routes/__tests__/StartupLayout.spec.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import StartupLayout from '~/routes/Layouts/Startup'; import { SESSION_KEY } from '~/utils'; -import StartupLayout from '../Layouts/Startup'; if (typeof Request === 'undefined') { global.Request = class Request {