From 9956a7269482b8259f3df39855f2a9c068f55819 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 5 Mar 2026 01:38:44 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Subdirectory=20Deployment?= =?UTF-8?q?=20Auth=20Redirect=20Path=20Doubling=20(#12077)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- client/src/hooks/AuthContext.tsx | 15 +- .../src/hooks/__tests__/AuthContext.spec.tsx | 66 ++++++++ .../routes/__tests__/useAuthRedirect.spec.tsx | 31 ++++ config/test-subdirectory-setup.sh | 144 ++++++++++++++++++ .../specs/api-endpoints-subdir.spec.ts | 140 +++++++++++++++++ .../data-provider/specs/api-endpoints.spec.ts | 38 ++--- .../specs/request-interceptor.spec.ts | 9 +- packages/data-provider/src/api-endpoints.ts | 15 +- packages/data-provider/src/request.ts | 2 +- 9 files changed, 426 insertions(+), 34 deletions(-) create mode 100644 config/test-subdirectory-setup.sh create mode 100644 packages/data-provider/specs/api-endpoints-subdir.spec.ts diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index f65479afcc..c55980c0d2 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -10,7 +10,12 @@ import { import { debounce } from 'lodash'; import { useRecoilState } from 'recoil'; import { useNavigate } from 'react-router-dom'; -import { setTokenHeader, SystemRoles, buildLoginRedirectUrl } from 'librechat-data-provider'; +import { + apiBaseUrl, + SystemRoles, + setTokenHeader, + buildLoginRedirectUrl, +} from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; import type { ReactNode } from 'react'; import { @@ -168,7 +173,13 @@ const AuthContextProvider = ({ if (token) { const storedRedirect = sessionStorage.getItem(SESSION_KEY); sessionStorage.removeItem(SESSION_KEY); - const currentUrl = `${window.location.pathname}${window.location.search}`; + const baseUrl = apiBaseUrl(); + const rawPath = window.location.pathname; + const strippedPath = + baseUrl && (rawPath === baseUrl || rawPath.startsWith(baseUrl + '/')) + ? rawPath.slice(baseUrl.length) || '/' + : rawPath; + const currentUrl = `${strippedPath}${window.location.search}`; const fallbackRedirect = isSafeRedirect(currentUrl) ? currentUrl : '/c/new'; const redirect = storedRedirect && isSafeRedirect(storedRedirect) ? storedRedirect : fallbackRedirect; diff --git a/client/src/hooks/__tests__/AuthContext.spec.tsx b/client/src/hooks/__tests__/AuthContext.spec.tsx index 4abf1ce77a..10a0ee3340 100644 --- a/client/src/hooks/__tests__/AuthContext.spec.tsx +++ b/client/src/hooks/__tests__/AuthContext.spec.tsx @@ -18,9 +18,12 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, })); +const mockApiBaseUrl = jest.fn(() => ''); + jest.mock('librechat-data-provider', () => ({ ...jest.requireActual('librechat-data-provider'), setTokenHeader: jest.fn(), + apiBaseUrl: () => mockApiBaseUrl(), })); let mockCapturedLoginOptions: { @@ -352,6 +355,69 @@ describe('AuthContextProvider — silentRefresh post-login redirect', () => { }); }); +describe('AuthContextProvider — silentRefresh subdirectory deployment', () => { + beforeEach(() => { + jest.clearAllMocks(); + sessionStorage.clear(); + mockApiBaseUrl.mockReturnValue('/chat'); + }); + + afterEach(() => { + mockApiBaseUrl.mockReturnValue(''); + sessionStorage.clear(); + window.history.replaceState({}, '', '/'); + }); + + it('strips base path from window.location.pathname before navigating (prevents /chat/chat doubling)', () => { + jest.useFakeTimers(); + window.history.replaceState({}, '', '/chat/c/abc123?model=gpt-4'); + + 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/abc123?model=gpt-4', { replace: true }); + expect(mockNavigate).not.toHaveBeenCalledWith( + expect.stringContaining('/chat/c/'), + expect.anything(), + ); + jest.useRealTimers(); + }); + + it('falls back to root when window.location.pathname equals the base path', () => { + jest.useFakeTimers(); + window.history.replaceState({}, '', '/chat'); + + renderProviderLive(); + + 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('/', { replace: true }); + jest.useRealTimers(); + }); +}); + describe('AuthContextProvider — logout error handling', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/client/src/routes/__tests__/useAuthRedirect.spec.tsx b/client/src/routes/__tests__/useAuthRedirect.spec.tsx index 2f3a47c022..adb06e15bc 100644 --- a/client/src/routes/__tests__/useAuthRedirect.spec.tsx +++ b/client/src/routes/__tests__/useAuthRedirect.spec.tsx @@ -245,6 +245,37 @@ describe('useAuthRedirect', () => { ); }); + it('should not include basename in redirect_to param (prevents path doubling)', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: null, + isAuthenticated: false, + }); + + /** + * Validates that React Router's useLocation() strips the basename before + * buildLoginRedirectUrl receives it, so redirect_to never contains + * the base prefix. The BASE_URL stripping logic inside buildLoginRedirectUrl + * (for callers using window.location.pathname) is tested in + * api-endpoints-subdir.spec.ts. + */ + const router = createTestRouter('/librechat', '/librechat/c/abc123'); + render(); + + await waitFor( + () => { + expect(router.state.location.pathname).toBe('/librechat/login'); + const search = router.state.location.search; + const params = new URLSearchParams(search); + const redirectTo = decodeURIComponent(params.get('redirect_to')!); + /** redirect_to should be /c/abc123, NOT /librechat/c/abc123 + * because navigate() with basename will re-add the prefix */ + expect(redirectTo).toBe('/c/abc123'); + expect(redirectTo).not.toContain('/librechat/'); + }, + { timeout: 1000 }, + ); + }); + it('should not append redirect_to when already on /login', async () => { (useAuthContext as jest.Mock).mockReturnValue({ user: null, diff --git a/config/test-subdirectory-setup.sh b/config/test-subdirectory-setup.sh new file mode 100644 index 0000000000..aafe84ce13 --- /dev/null +++ b/config/test-subdirectory-setup.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# ============================================================================= +# Test script for verifying subdirectory deployment (e.g., /chat/) +# +# Prerequisites: +# - nginx installed: sudo apt install nginx +# - LibreChat built: npm run build +# - Backend running: npm run backend (serves built SPA + API on port 3080) +# +# Usage: +# 1. Build + start: npm run build && npm run backend +# 2. Run this script: bash config/test-subdirectory-setup.sh start +# 3. Open browser: http://localhost:8080/chat/ +# 4. Cleanup: bash config/test-subdirectory-setup.sh stop +# +# What to verify: +# - Accessing http://localhost:8080/chat/ should redirect to /chat/login +# (NOT /chat/chat/login) +# - After login, navigating to protected routes should work +# - Logging out and being redirected should not double the path +# ============================================================================= + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +NGINX_CONF="/tmp/librechat-subdir-test-nginx.conf" +NGINX_PID="/tmp/librechat-subdir-test-nginx.pid" + +ENV_FILE="${REPO_ROOT}/.env" + +write_nginx_conf() { + cat > "$NGINX_CONF" << 'NGINX' +worker_processes 1; +pid /tmp/librechat-subdir-test-nginx.pid; +error_log /tmp/librechat-subdir-test-nginx-error.log warn; + +events { + worker_connections 64; +} + +http { + access_log /tmp/librechat-subdir-test-nginx-access.log; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 8080; + server_name localhost; + + # Subdirectory proxy: strip /chat/ prefix and forward to backend + location /chat/ { + proxy_pass http://127.0.0.1:3080/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Redirect bare /chat to /chat/ + location = /chat { + return 301 /chat/; + } + } +} +NGINX +} + +start() { + echo "--- Setting up subdirectory test environment ---" + + # Backup .env if it exists and doesn't have our marker + if [ -f "$ENV_FILE" ] && ! grep -q '## SUBDIR_TEST_MARKER' "$ENV_FILE"; then + cp "$ENV_FILE" "${ENV_FILE}.bak-subdir-test" + echo "Backed up .env to .env.bak-subdir-test" + fi + + # Ensure DOMAIN_CLIENT and DOMAIN_SERVER are set for subdirectory + if ! grep -q 'DOMAIN_CLIENT=http://localhost:8080/chat' "$ENV_FILE" 2>/dev/null; then + echo "" + echo "You need to set these in your .env file:" + echo " DOMAIN_CLIENT=http://localhost:8080/chat" + echo " DOMAIN_SERVER=http://localhost:8080/chat" + echo "" + echo "Then restart the backend: npm run backend" + echo "" + fi + + # Write and start nginx + write_nginx_conf + echo "Starting nginx on port 8080 with subdirectory /chat/ ..." + + # Stop any existing test nginx + if [ -f "$NGINX_PID" ] && kill -0 "$(cat "$NGINX_PID")" 2>/dev/null; then + nginx -c "$NGINX_CONF" -s stop 2>/dev/null || true + sleep 1 + fi + + nginx -c "$NGINX_CONF" + echo "nginx started (PID: $(cat "$NGINX_PID" 2>/dev/null || echo 'unknown'))" + echo "" + echo "=== Test URLs ===" + echo " Main: http://localhost:8080/chat/" + echo " Login: http://localhost:8080/chat/login" + echo " Expect: Redirects should go to /chat/login, NOT /chat/chat/login" + echo "" + echo "=== Logs ===" + echo " Access: /tmp/librechat-subdir-test-nginx-access.log" + echo " Error: /tmp/librechat-subdir-test-nginx-error.log" + echo "" + echo "Run '$0 stop' to clean up." +} + +stop() { + echo "--- Cleaning up subdirectory test environment ---" + + if [ -f "$NGINX_PID" ] && kill -0 "$(cat "$NGINX_PID")" 2>/dev/null; then + nginx -c "$NGINX_CONF" -s stop + echo "nginx stopped." + else + echo "nginx not running." + fi + + rm -f "$NGINX_CONF" /tmp/librechat-subdir-test-nginx-*.log + + if [ -f "${ENV_FILE}.bak-subdir-test" ]; then + echo "Restore .env backup: cp ${ENV_FILE}.bak-subdir-test ${ENV_FILE}" + fi +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + *) + echo "Usage: $0 {start|stop}" + exit 1 + ;; +esac diff --git a/packages/data-provider/specs/api-endpoints-subdir.spec.ts b/packages/data-provider/specs/api-endpoints-subdir.spec.ts new file mode 100644 index 0000000000..172f9d6618 --- /dev/null +++ b/packages/data-provider/specs/api-endpoints-subdir.spec.ts @@ -0,0 +1,140 @@ +/** + * @jest-environment jsdom + */ + +/** + * Tests for buildLoginRedirectUrl and apiBaseUrl under subdirectory deployments. + * + * Uses jest.isolateModules to re-import api-endpoints with a + * element present, simulating a subdirectory deployment where BASE_URL = '/chat'. + * + * Tests that need to override window.location use explicit function arguments + * instead of mocking the global, since jsdom 26+ does not allow redefining it. + */ + +function loadModuleWithBase(baseHref: string) { + const base = document.createElement('base'); + base.setAttribute('href', baseHref); + document.head.appendChild(base); + + const proc = process as typeof process & { browser?: boolean }; + const originalBrowser = proc.browser; + + let mod: typeof import('../src/api-endpoints'); + try { + proc.browser = true; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- static import not usable inside isolateModules + mod = require('../src/api-endpoints'); + }); + return mod!; + } finally { + proc.browser = originalBrowser; + document.head.removeChild(base); + } +} + +describe('buildLoginRedirectUrl — subdirectory deployment (BASE_URL = /chat)', () => { + let buildLoginRedirectUrl: typeof import('../src/api-endpoints').buildLoginRedirectUrl; + let apiBaseUrl: typeof import('../src/api-endpoints').apiBaseUrl; + + beforeAll(() => { + const mod = loadModuleWithBase('/chat/'); + buildLoginRedirectUrl = mod.buildLoginRedirectUrl; + apiBaseUrl = mod.apiBaseUrl; + }); + + it('sets BASE_URL to "/chat" (trailing slash stripped)', () => { + expect(apiBaseUrl()).toBe('/chat'); + }); + + it('returns "/login" without base prefix (compatible with React Router navigate)', () => { + const result = buildLoginRedirectUrl('/chat/c/new', '', ''); + expect(result).toMatch(/^\/login/); + expect(result).not.toMatch(/^\/chat/); + }); + + it('strips base prefix from redirect_to when pathname includes base', () => { + const result = buildLoginRedirectUrl('/chat/c/abc123', '?model=gpt-4', ''); + const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]); + expect(redirectTo).toBe('/c/abc123?model=gpt-4'); + expect(redirectTo).not.toContain('/chat/'); + }); + + it('works with pathnames that do not include the base prefix', () => { + const result = buildLoginRedirectUrl('/c/new', '', ''); + const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]); + expect(redirectTo).toBe('/c/new'); + }); + + it('returns plain /login for base-prefixed login path', () => { + expect(buildLoginRedirectUrl('/chat/login', '', '')).toBe('/login'); + }); + + it('returns plain /login for base-prefixed login sub-path', () => { + expect(buildLoginRedirectUrl('/chat/login/2fa', '', '')).toBe('/login'); + }); + + it('returns plain /login when stripped path is root (no pointless redirect_to=/)', () => { + const result = buildLoginRedirectUrl('/chat', '', ''); + expect(result).toBe('/login'); + expect(result).not.toContain('redirect_to'); + }); + + it('composes correct full URL for window.location.href (apiBaseUrl + buildLoginRedirectUrl)', () => { + const fullUrl = apiBaseUrl() + buildLoginRedirectUrl('/chat/c/abc123', '', ''); + expect(fullUrl).toBe('/chat/login?redirect_to=%2Fc%2Fabc123'); + expect(fullUrl).not.toContain('/chat/chat/'); + }); + + it('encodes query params and hash correctly after stripping base', () => { + const result = buildLoginRedirectUrl('/chat/c/deep', '?q=hello&submit=true', '#section'); + const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]); + expect(redirectTo).toBe('/c/deep?q=hello&submit=true#section'); + }); + + it('does not strip base when path shares a prefix but is not a segment match', () => { + const result = buildLoginRedirectUrl('/chatroom/c/abc123', '', ''); + const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]); + expect(redirectTo).toBe('/chatroom/c/abc123'); + }); + + it('does not strip base from /chatbot path', () => { + const result = buildLoginRedirectUrl('/chatbot', '', ''); + const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]); + expect(redirectTo).toBe('/chatbot'); + }); +}); + +describe('buildLoginRedirectUrl — deep subdirectory (BASE_URL = /app/chat)', () => { + let buildLoginRedirectUrl: typeof import('../src/api-endpoints').buildLoginRedirectUrl; + let apiBaseUrl: typeof import('../src/api-endpoints').apiBaseUrl; + + beforeAll(() => { + const mod = loadModuleWithBase('/app/chat/'); + buildLoginRedirectUrl = mod.buildLoginRedirectUrl; + apiBaseUrl = mod.apiBaseUrl; + }); + + it('sets BASE_URL to "/app/chat"', () => { + expect(apiBaseUrl()).toBe('/app/chat'); + }); + + it('strips deep base prefix from redirect_to', () => { + const result = buildLoginRedirectUrl('/app/chat/c/abc123', '', ''); + const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]); + expect(redirectTo).toBe('/c/abc123'); + }); + + it('full URL does not double the base prefix', () => { + const fullUrl = apiBaseUrl() + buildLoginRedirectUrl('/app/chat/c/abc123', '', ''); + expect(fullUrl).toBe('/app/chat/login?redirect_to=%2Fc%2Fabc123'); + expect(fullUrl).not.toContain('/app/chat/app/chat/'); + }); + + it('does not strip from /app/chatroom (segment boundary check)', () => { + const result = buildLoginRedirectUrl('/app/chatroom/page', '', ''); + const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]); + expect(redirectTo).toBe('/app/chatroom/page'); + }); +}); diff --git a/packages/data-provider/specs/api-endpoints.spec.ts b/packages/data-provider/specs/api-endpoints.spec.ts index 47257d9b33..b582bb1ef0 100644 --- a/packages/data-provider/specs/api-endpoints.spec.ts +++ b/packages/data-provider/specs/api-endpoints.spec.ts @@ -4,18 +4,8 @@ import { buildLoginRedirectUrl } from '../src/api-endpoints'; describe('buildLoginRedirectUrl', () => { - let savedLocation: Location; - - beforeEach(() => { - savedLocation = window.location; - Object.defineProperty(window, 'location', { - value: { pathname: '/c/abc123', search: '?model=gpt-4', hash: '#msg-5' }, - writable: true, - }); - }); - afterEach(() => { - Object.defineProperty(window, 'location', { value: savedLocation, writable: true }); + window.history.replaceState({}, '', '/'); }); it('builds a login URL from explicit args', () => { @@ -31,18 +21,16 @@ describe('buildLoginRedirectUrl', () => { }); it('falls back to window.location when no args provided', () => { + window.history.replaceState({}, '', '/c/abc123?model=gpt-4#msg-5'); const result = buildLoginRedirectUrl(); const encoded = result.split('redirect_to=')[1]; expect(decodeURIComponent(encoded)).toBe('/c/abc123?model=gpt-4#msg-5'); }); - it('falls back to "/" when all location parts are empty', () => { - Object.defineProperty(window, 'location', { - value: { pathname: '', search: '', hash: '' }, - writable: true, - }); + it('returns plain /login when all location parts are empty (root)', () => { + window.history.replaceState({}, '', '/'); const result = buildLoginRedirectUrl(); - expect(result).toBe('/login?redirect_to=%2F'); + expect(result).toBe('/login'); }); it('returns plain /login when pathname is /login (prevents recursive redirect)', () => { @@ -51,10 +39,7 @@ describe('buildLoginRedirectUrl', () => { }); it('returns plain /login when window.location is already /login', () => { - Object.defineProperty(window, 'location', { - value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' }, - writable: true, - }); + window.history.replaceState({}, '', '/login?redirect_to=%2Fc%2Fabc'); const result = buildLoginRedirectUrl(); expect(result).toBe('/login'); }); @@ -65,10 +50,7 @@ describe('buildLoginRedirectUrl', () => { }); it('returns plain /login for basename-prefixed /login (e.g. /librechat/login)', () => { - Object.defineProperty(window, 'location', { - value: { pathname: '/librechat/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' }, - writable: true, - }); + window.history.replaceState({}, '', '/librechat/login?redirect_to=%2Fc%2Fabc'); const result = buildLoginRedirectUrl(); expect(result).toBe('/login'); }); @@ -78,6 +60,12 @@ describe('buildLoginRedirectUrl', () => { expect(result).toBe('/login'); }); + it('returns plain /login for root path (no pointless redirect_to=/)', () => { + const result = buildLoginRedirectUrl('/', '', ''); + expect(result).toBe('/login'); + expect(result).not.toContain('redirect_to'); + }); + it('does NOT match paths where "login" is a substring of a segment', () => { const result = buildLoginRedirectUrl('/c/loginhistory', '', ''); expect(result).toContain('redirect_to='); diff --git a/packages/data-provider/specs/request-interceptor.spec.ts b/packages/data-provider/specs/request-interceptor.spec.ts index a36d5592aa..b5e43736cc 100644 --- a/packages/data-provider/specs/request-interceptor.spec.ts +++ b/packages/data-provider/specs/request-interceptor.spec.ts @@ -1,16 +1,19 @@ /** - * @jest-environment jsdom + * @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'` (jsdom provides window). + * `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(); @@ -38,6 +41,7 @@ afterEach(() => { Object.defineProperty(window, 'location', { value: savedLocation, writable: true, + configurable: true, }); }); @@ -45,6 +49,7 @@ function setWindowLocation(overrides: Partial) { Object.defineProperty(window, 'location', { value: { ...window.location, ...overrides }, writable: true, + configurable: true, }); } diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index f70237edee..762a0ce859 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -174,13 +174,20 @@ const LOGIN_PATH_RE = /(?:^|\/)login(?:\/|$)/; export function buildLoginRedirectUrl(pathname?: string, search?: string, hash?: string): string { const p = pathname ?? window.location.pathname; if (LOGIN_PATH_RE.test(p)) { - return `${BASE_URL}/login`; + return '/login'; } const s = search ?? window.location.search; const h = hash ?? window.location.hash; - const currentPath = `${p}${s}${h}`; - const encoded = encodeURIComponent(currentPath || '/'); - return `${BASE_URL}/login?${REDIRECT_PARAM}=${encoded}`; + + const stripped = + BASE_URL && (p === BASE_URL || p.startsWith(BASE_URL + '/')) + ? p.slice(BASE_URL.length) || '/' + : p; + const currentPath = `${stripped}${s}${h}`; + if (!currentPath || currentPath === '/') { + return '/login'; + } + return `/login?${REDIRECT_PARAM}=${encodeURIComponent(currentPath)}`; } export const resendVerificationEmail = () => `${BASE_URL}/api/user/verify/resend`; diff --git a/packages/data-provider/src/request.ts b/packages/data-provider/src/request.ts index 566d808e63..5021b150e3 100644 --- a/packages/data-provider/src/request.ts +++ b/packages/data-provider/src/request.ts @@ -141,7 +141,7 @@ if (typeof window !== 'undefined') { return await axios(originalRequest); } else { processQueue(error, null); - window.location.href = endpoints.buildLoginRedirectUrl(); + window.location.href = endpoints.apiBaseUrl() + endpoints.buildLoginRedirectUrl(); } } catch (err) { processQueue(err as AxiosError, null);