mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-14 19:56:34 +01:00
175 lines
4.9 KiB
TypeScript
175 lines
4.9 KiB
TypeScript
|
|
import React from 'react';
|
||
|
|
import { render, act } from '@testing-library/react';
|
||
|
|
import { RecoilRoot } from 'recoil';
|
||
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||
|
|
import { MemoryRouter } from 'react-router-dom';
|
||
|
|
|
||
|
|
import type { TAuthConfig } from '~/common';
|
||
|
|
|
||
|
|
import { AuthContextProvider, useAuthContext } from '../AuthContext';
|
||
|
|
|
||
|
|
const mockNavigate = jest.fn();
|
||
|
|
jest.mock('react-router-dom', () => ({
|
||
|
|
...jest.requireActual('react-router-dom'),
|
||
|
|
useNavigate: () => mockNavigate,
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('librechat-data-provider', () => ({
|
||
|
|
...jest.requireActual('librechat-data-provider'),
|
||
|
|
setTokenHeader: jest.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
let mockCapturedLoginOptions: {
|
||
|
|
onSuccess: (...args: unknown[]) => void;
|
||
|
|
onError: (...args: unknown[]) => void;
|
||
|
|
};
|
||
|
|
|
||
|
|
jest.mock('~/data-provider', () => ({
|
||
|
|
useLoginUserMutation: jest.fn(
|
||
|
|
(options: {
|
||
|
|
onSuccess: (...args: unknown[]) => void;
|
||
|
|
onError: (...args: unknown[]) => void;
|
||
|
|
}) => {
|
||
|
|
mockCapturedLoginOptions = options;
|
||
|
|
return { mutate: jest.fn() };
|
||
|
|
},
|
||
|
|
),
|
||
|
|
useLogoutUserMutation: jest.fn(() => ({ mutate: jest.fn() })),
|
||
|
|
useRefreshTokenMutation: jest.fn(() => ({ mutate: jest.fn() })),
|
||
|
|
useGetUserQuery: jest.fn(() => ({
|
||
|
|
data: undefined,
|
||
|
|
isError: false,
|
||
|
|
error: null,
|
||
|
|
})),
|
||
|
|
useGetRole: jest.fn(() => ({ data: null })),
|
||
|
|
}));
|
||
|
|
|
||
|
|
const authConfig: TAuthConfig = { loginRedirect: '/login', test: true };
|
||
|
|
|
||
|
|
function TestConsumer() {
|
||
|
|
const ctx = useAuthContext();
|
||
|
|
return <div data-testid="consumer" data-authenticated={ctx.isAuthenticated} />;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderProvider() {
|
||
|
|
const queryClient = new QueryClient({
|
||
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||
|
|
});
|
||
|
|
|
||
|
|
return render(
|
||
|
|
<QueryClientProvider client={queryClient}>
|
||
|
|
<RecoilRoot>
|
||
|
|
<MemoryRouter>
|
||
|
|
<AuthContextProvider authConfig={authConfig}>
|
||
|
|
<TestConsumer />
|
||
|
|
</AuthContextProvider>
|
||
|
|
</MemoryRouter>
|
||
|
|
</RecoilRoot>
|
||
|
|
</QueryClientProvider>,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('AuthContextProvider — login onError redirect handling', () => {
|
||
|
|
const originalLocation = window.location;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
jest.clearAllMocks();
|
||
|
|
Object.defineProperty(window, 'location', {
|
||
|
|
value: { ...originalLocation, pathname: '/login', search: '', hash: '' },
|
||
|
|
writable: true,
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
Object.defineProperty(window, 'location', {
|
||
|
|
value: originalLocation,
|
||
|
|
writable: true,
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('preserves a valid redirect_to param across login failure', () => {
|
||
|
|
Object.defineProperty(window, 'location', {
|
||
|
|
value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc123', hash: '' },
|
||
|
|
writable: true,
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
renderProvider();
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(mockNavigate).toHaveBeenCalledWith('/login?redirect_to=%2Fc%2Fabc123', {
|
||
|
|
replace: true,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('drops redirect_to when it contains an absolute URL (open-redirect prevention)', () => {
|
||
|
|
Object.defineProperty(window, 'location', {
|
||
|
|
value: { pathname: '/login', search: '?redirect_to=https%3A%2F%2Fevil.com', hash: '' },
|
||
|
|
writable: true,
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
renderProvider();
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('drops redirect_to when it points to /login (recursive redirect prevention)', () => {
|
||
|
|
Object.defineProperty(window, 'location', {
|
||
|
|
value: { pathname: '/login', search: '?redirect_to=%2Flogin', hash: '' },
|
||
|
|
writable: true,
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
renderProvider();
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('navigates to plain /login when no redirect_to param exists', () => {
|
||
|
|
renderProvider();
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
mockCapturedLoginOptions.onError({ message: 'Server error' });
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('preserves redirect_to with query params and hash', () => {
|
||
|
|
const target = '/c/abc123?model=gpt-4#section';
|
||
|
|
Object.defineProperty(window, 'location', {
|
||
|
|
value: {
|
||
|
|
pathname: '/login',
|
||
|
|
search: `?redirect_to=${encodeURIComponent(target)}`,
|
||
|
|
hash: '',
|
||
|
|
},
|
||
|
|
writable: true,
|
||
|
|
configurable: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
renderProvider();
|
||
|
|
|
||
|
|
act(() => {
|
||
|
|
mockCapturedLoginOptions.onError({ message: 'Invalid credentials' });
|
||
|
|
});
|
||
|
|
|
||
|
|
const navigatedUrl = mockNavigate.mock.calls[0][0] as string;
|
||
|
|
const params = new URLSearchParams(navigatedUrl.split('?')[1]);
|
||
|
|
expect(decodeURIComponent(params.get('redirect_to')!)).toBe(target);
|
||
|
|
});
|
||
|
|
});
|