mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-07 16:42:38 +01:00
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* fix: subdirectory redirects * fix: use path-segment boundary check when stripping BASE_URL prefix A bare `startsWith(BASE_URL)` matches on character prefix, not path segments. With BASE_URL="/chat", a path like "/chatroom/c/abc" would incorrectly strip to "room/c/abc" (no leading slash). Guard with an exact-match-or-slash check: `p === BASE_URL || p.startsWith(BASE_URL + '/')`. Also removes the dead `BASE_URL !== '/'` guard — module init already converts '/' to ''. * test: add path-segment boundary tests and clarify subdirectory coverage - Add /chatroom, /chatbot, /app/chatroom regression tests to verify BASE_URL stripping only matches on segment boundaries - Clarify useAuthRedirect subdirectory test documents React Router basename behavior (BASE_URL stripping tested in api-endpoints-subdir) - Use `delete proc.browser` instead of undefined assignment for cleanup - Add rationale to eslint-disable comment for isolateModules require * fix: use relative path and correct instructions in subdirectory test script - Replace hardcoded /home/danny/LibreChat/.env with repo-root-relative path so the script works from any checkout location - Update instructions to use production build (npm run build && npm run backend) since nginx proxies to :3080 which only serves the SPA after a full build, not during frontend:dev on :3090 * fix: skip pointless redirect_to=/ for root path and fix jsdom 26+ compat buildLoginRedirectUrl now returns plain /login when the resolved path is root — redirect_to=/ adds no value since / immediately redirects to /c/new after login anyway. Also rewrites api-endpoints.spec.ts to use window.history.replaceState instead of Object.defineProperty(window, 'location', ...) which jsdom 26+ no longer allows. * test: fix request-interceptor.spec.ts for jsdom 26+ compatibility Switch from jsdom to happy-dom environment which allows Object.defineProperty on window.location. jsdom 26+ made location non-configurable, breaking all 8 tests in this file. * chore: update browser property handling in api-endpoints-subdir test Changed the handling of the `proc.browser` property from deletion to setting it to false, ensuring compatibility with the current testing environment. * chore: update backend restart instructions in test subdirectory setup script Changed the instruction for restarting the backend from "npm run backend:dev" to "npm run backend" to reflect the correct command for the current setup. * refactor: ensure proper cleanup in loadModuleWithBase function Wrapped the module loading logic in a try-finally block to guarantee that the `proc.browser` property is reset to false and the base element is removed, improving reliability in the testing environment. * refactor: improve browser property handling in loadModuleWithBase function Revised the management of the `proc.browser` property to store the original value before modification, ensuring it is restored correctly after module loading. This enhances the reliability of the testing environment.
304 lines
7.4 KiB
TypeScript
304 lines
7.4 KiB
TypeScript
/**
|
|
* @jest-environment @happy-dom/jest-environment
|
|
*/
|
|
import axios from 'axios';
|
|
import { setTokenHeader } from '../src/headers-helpers';
|
|
|
|
/**
|
|
* The response interceptor in request.ts registers at import time when
|
|
* `typeof window !== 'undefined'` (happy-dom provides window).
|
|
*
|
|
* We use axios's built-in request adapter mock to avoid real HTTP calls,
|
|
* and verify the interceptor's behavior by observing whether a 401 triggers
|
|
* a refresh POST or is immediately rejected.
|
|
*
|
|
* happy-dom is used instead of jsdom because it allows overriding
|
|
* window.location via Object.defineProperty, which jsdom 26+ blocks.
|
|
*/
|
|
|
|
const mockAdapter = jest.fn();
|
|
let originalAdapter: typeof axios.defaults.adapter;
|
|
let savedLocation: Location;
|
|
|
|
beforeAll(async () => {
|
|
originalAdapter = axios.defaults.adapter;
|
|
axios.defaults.adapter = mockAdapter;
|
|
|
|
await import('../src/request');
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockAdapter.mockReset();
|
|
savedLocation = window.location;
|
|
});
|
|
|
|
afterAll(() => {
|
|
axios.defaults.adapter = originalAdapter;
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete axios.defaults.headers.common['Authorization'];
|
|
Object.defineProperty(window, 'location', {
|
|
value: savedLocation,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
function setWindowLocation(overrides: Partial<Location>) {
|
|
Object.defineProperty(window, 'location', {
|
|
value: { ...window.location, ...overrides },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
describe('axios 401 interceptor — Authorization header guard', () => {
|
|
it('skips refresh and rejects when Authorization header is cleared', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader(undefined);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/api/messages', headers: {} },
|
|
});
|
|
|
|
try {
|
|
await axios.get('/api/messages');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(mockAdapter).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('attempts refresh on shared link page even without Authorization header', async () => {
|
|
expect.assertions(2);
|
|
setTokenHeader(undefined);
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/share/abc123',
|
|
pathname: '/share/abc123',
|
|
search: '',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/api/share/abc123', headers: {} },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: 'new-token' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { sharedLink: {} },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/api/share/abc123');
|
|
} catch {
|
|
// may reject depending on exact flow
|
|
}
|
|
|
|
expect(mockAdapter.mock.calls.length).toBe(3);
|
|
|
|
const refreshCall = mockAdapter.mock.calls[1];
|
|
expect(refreshCall[0].url).toContain('api/auth/refresh');
|
|
});
|
|
|
|
it('does not bypass guard when share/ appears only in query params', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader(undefined);
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/c/chat?ref=share/token',
|
|
pathname: '/c/chat',
|
|
search: '?ref=share/token',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/api/messages', headers: {} },
|
|
});
|
|
|
|
try {
|
|
await axios.get('/api/messages');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(mockAdapter).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('redirects to login with redirect_to when unauthenticated on share page and refresh fails', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader(undefined);
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/share/abc123',
|
|
pathname: '/share/abc123',
|
|
search: '',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/api/share/abc123', headers: {} },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: '' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/api/share/abc123');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123');
|
|
});
|
|
|
|
it('redirects to login with redirect_to when authenticated and refresh returns no token on share page', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader('some-token');
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/share/abc123',
|
|
pathname: '/share/abc123',
|
|
search: '',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/api/share/abc123', headers: {} },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: '' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/api/share/abc123');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123');
|
|
});
|
|
|
|
it('redirects to login with redirect_to when refresh returns no token on regular page', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader('some-token');
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/c/some-conversation',
|
|
pathname: '/c/some-conversation',
|
|
search: '',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/api/messages', headers: {} },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: '' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/api/messages');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(window.location.href).toBe('/login?redirect_to=%2Fc%2Fsome-conversation');
|
|
});
|
|
|
|
it('redirects to plain /login without redirect_to when already on a login path', async () => {
|
|
expect.assertions(1);
|
|
setTokenHeader('some-token');
|
|
|
|
setWindowLocation({
|
|
href: 'http://localhost/login/2fa',
|
|
pathname: '/login/2fa',
|
|
search: '',
|
|
hash: '',
|
|
} as Partial<Location>);
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/api/messages', headers: {} },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: '' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/api/messages');
|
|
} catch {
|
|
// expected rejection
|
|
}
|
|
|
|
expect(window.location.href).toBe('/login');
|
|
});
|
|
|
|
it('attempts refresh when Authorization header is present', async () => {
|
|
expect.assertions(2);
|
|
setTokenHeader('valid-token');
|
|
|
|
mockAdapter.mockRejectedValueOnce({
|
|
response: { status: 401 },
|
|
config: { url: '/api/messages', headers: {}, _retry: false },
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { token: 'new-token' },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
mockAdapter.mockResolvedValueOnce({
|
|
data: { messages: [] },
|
|
status: 200,
|
|
headers: {},
|
|
config: {},
|
|
});
|
|
|
|
try {
|
|
await axios.get('/api/messages');
|
|
} catch {
|
|
// may reject depending on exact flow
|
|
}
|
|
|
|
expect(mockAdapter.mock.calls.length).toBe(3);
|
|
|
|
const refreshCall = mockAdapter.mock.calls[1];
|
|
expect(refreshCall[0].url).toContain('api/auth/refresh');
|
|
});
|
|
});
|