This commit is contained in:
Danny Avila 2026-05-13 01:27:48 +08:00 committed by GitHub
commit 7afa40d914
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 101 additions and 5 deletions

View file

@ -85,7 +85,7 @@ describe('axios 401 interceptor — Authorization header guard', () => {
mockAdapter.mockRejectedValueOnce({
response: { status: 401 },
config: { url: '/api/share/abc123', headers: {} },
config: { url: '/api/share/abc123', method: 'get', headers: {} },
});
mockAdapter.mockResolvedValueOnce({
@ -114,6 +114,75 @@ describe('axios 401 interceptor — Authorization header guard', () => {
expect(refreshCall[0].url).toContain('api/auth/refresh');
});
it('recognizes base-prefixed shared link data requests', async () => {
expect.assertions(2);
setTokenHeader(undefined);
setWindowLocation({
href: 'http://localhost/chat/share/abc123',
pathname: '/chat/share/abc123',
search: '',
hash: '',
origin: 'http://localhost',
} as Partial<Location>);
mockAdapter.mockRejectedValueOnce({
response: { status: 401 },
config: { url: '/chat/api/share/abc123', method: 'get', headers: {} },
});
mockAdapter.mockResolvedValueOnce({
data: { token: 'new-token' },
status: 200,
headers: {},
config: {},
});
mockAdapter.mockResolvedValueOnce({
data: { sharedLink: {} },
status: 200,
headers: {},
config: {},
});
try {
await axios.get('/chat/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 refresh or redirect for unrelated 401s on public shared link pages', 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/mcp/servers', headers: {} },
});
try {
await axios.get('/api/mcp/servers');
} catch {
// expected rejection
}
expect(mockAdapter).toHaveBeenCalledTimes(1);
expect(window.location.href).toBe('http://localhost/share/abc123');
});
it('does not bypass guard when share/ appears only in query params', async () => {
expect.assertions(1);
setTokenHeader(undefined);
@ -152,7 +221,7 @@ describe('axios 401 interceptor — Authorization header guard', () => {
mockAdapter.mockRejectedValueOnce({
response: { status: 401 },
config: { url: '/api/share/abc123', headers: {} },
config: { url: '/api/share/abc123', method: 'get', headers: {} },
});
mockAdapter.mockResolvedValueOnce({
@ -184,7 +253,7 @@ describe('axios 401 interceptor — Authorization header guard', () => {
mockAdapter.mockRejectedValueOnce({
response: { status: 401 },
config: { url: '/api/share/abc123', headers: {} },
config: { url: '/api/share/abc123', method: 'get', headers: {} },
});
mockAdapter.mockResolvedValueOnce({

View file

@ -67,6 +67,32 @@ let failedQueue: { resolve: (value?: any) => void; reject: (reason?: any) => voi
const refreshToken = (retry?: boolean): Promise<t.TRefreshTokenResponse | undefined> =>
_post(endpoints.refreshToken(retry));
const stripBasePath = (pathname: string) => {
const baseUrl = endpoints.apiBaseUrl();
if (baseUrl && (pathname === baseUrl || pathname.startsWith(`${baseUrl}/`))) {
return pathname.slice(baseUrl.length) || '/';
}
return pathname;
};
const isSharePage = () =>
/(?:^|\/)share\/[^/]+\/?$/.test(stripBasePath(window.location.pathname));
const getRequestPathname = (url?: string) => {
if (typeof url !== 'string') {
return '';
}
try {
return new URL(url, window.location.origin).pathname;
} catch {
return url.split(/[?#]/)[0] ?? '';
}
};
const isSharedMessagesRequest = (url?: string, method?: string) =>
method?.toLowerCase() === 'get' &&
/(?:^|\/)api\/share\/[^/]+$/.test(getRequestPathname(url));
const dispatchTokenUpdatedEvent = (token: string) => {
setTokenHeader(token);
window.dispatchEvent(new CustomEvent('tokenUpdated', { detail: token }));
@ -100,10 +126,11 @@ if (typeof window !== 'undefined') {
}
/** Skip refresh when the Authorization header has been cleared (e.g. during logout),
* but allow shared link requests to proceed so auth recovery/redirect can happen */
* but allow the shared link data request to proceed so private shares can still
* recover auth/redirect without unrelated share-page queries forcing login. */
if (
!axios.defaults.headers.common['Authorization'] &&
!window.location.pathname.startsWith('/share/')
!(isSharePage() && isSharedMessagesRequest(originalRequest.url, originalRequest.method))
) {
return Promise.reject(error);
}