mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 05:40:58 +00:00
getDaemonToken() read the token from the #token= fragment into a module-level cache, and removeDaemonTokenFromUrl() then deliberately stripped it from the URL for history hygiene. Nothing persisted it, so a page refresh loaded with no credential at all — every token-gated API call returned 401 until the user hand-rebuilt the URL (#7301). The first load now writes the URL token to sessionStorage (per-tab, so it clears when the tab closes and never leaks across tabs the way localStorage would), and later loads fall back to that copy when the URL carries no token. A fresh URL token always wins over a stale persisted one, storage failures (privacy modes, storage-disabled embeds) degrade to the old memory-only behavior, and the URL-stripping hygiene is unchanged. Verified with a real-browser E2E (real `qwen serve --token` + Playwright, hard reload in between): before, the reloaded page sends 0 authorized requests and token-gated calls 401; after, all reloaded requests carry the bearer and the probe returns 200. The jsdom refresh regression test fails on the unpatched source. Fixes #7301 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
296 lines
9 KiB
TypeScript
296 lines
9 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { afterEach, describe, expect, it, beforeEach, vi } from 'vitest';
|
|
|
|
describe('getAllowedDaemonOrigin (via getDaemonBaseUrl)', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
function setup(pageUrl: string) {
|
|
const url = new URL(pageUrl);
|
|
Object.defineProperty(window, 'location', {
|
|
value: {
|
|
origin: url.origin,
|
|
protocol: url.protocol,
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
href: url.href,
|
|
search: url.search,
|
|
},
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
async function getDaemonBaseUrlWith(pageUrl: string, daemonParam: string) {
|
|
setup(pageUrl);
|
|
Object.defineProperty(window, 'location', {
|
|
value: {
|
|
...window.location,
|
|
search: `?daemon=${encodeURIComponent(daemonParam)}`,
|
|
},
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
const mod = await import('./daemon');
|
|
return mod.getDaemonBaseUrl();
|
|
}
|
|
|
|
it('accepts same-origin daemon URL', async () => {
|
|
setup('http://localhost:5173');
|
|
Object.defineProperty(window, 'location', {
|
|
value: {
|
|
...window.location,
|
|
search: '?daemon=http://localhost:5173',
|
|
},
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
const mod = await import('./daemon');
|
|
expect(mod.getDaemonBaseUrl()).toBe('http://localhost:5173');
|
|
});
|
|
|
|
it('rejects external host', async () => {
|
|
const result = await getDaemonBaseUrlWith(
|
|
'http://localhost:5173',
|
|
'http://evil.com:5173',
|
|
);
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
it('rejects non-HTTP scheme', async () => {
|
|
const result = await getDaemonBaseUrlWith(
|
|
'http://localhost:5173',
|
|
'ftp://localhost:5173',
|
|
);
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
it('rejects localhost with different port', async () => {
|
|
const result = await getDaemonBaseUrlWith(
|
|
'http://localhost:5173',
|
|
'http://localhost:4170',
|
|
);
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
it('returns empty for non-parseable URL', async () => {
|
|
const result = await getDaemonBaseUrlWith(
|
|
'http://localhost:5173',
|
|
'not-a-valid-url:///',
|
|
);
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
it('returns empty when no daemon param', async () => {
|
|
setup('http://localhost:5173');
|
|
Object.defineProperty(window, 'location', {
|
|
value: { ...window.location, search: '' },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
const mod = await import('./daemon');
|
|
expect(mod.getDaemonBaseUrl()).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('getDaemonToken', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
// The token now persists per-tab (#7301); isolate tests from each
|
|
// other's persisted copies.
|
|
window.sessionStorage.clear();
|
|
});
|
|
|
|
function setupToken(search: string, hash: string) {
|
|
Object.defineProperty(window, 'location', {
|
|
value: { search, hash, href: `http://localhost:4170/${search}${hash}` },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
it('reads the token from the URL fragment', async () => {
|
|
setupToken('', '#token=frag-secret');
|
|
const mod = await import('./daemon');
|
|
expect(mod.getDaemonToken()).toBe('frag-secret');
|
|
});
|
|
|
|
it('falls back to the query parameter', async () => {
|
|
setupToken('?token=query-secret', '');
|
|
const mod = await import('./daemon');
|
|
expect(mod.getDaemonToken()).toBe('query-secret');
|
|
});
|
|
|
|
it('prefers the fragment over the query parameter', async () => {
|
|
setupToken('?token=query-secret', '#token=frag-secret');
|
|
const mod = await import('./daemon');
|
|
expect(mod.getDaemonToken()).toBe('frag-secret');
|
|
});
|
|
|
|
it('returns undefined when neither is present', async () => {
|
|
setupToken('', '');
|
|
const mod = await import('./daemon');
|
|
expect(mod.getDaemonToken()).toBeUndefined();
|
|
});
|
|
|
|
// Regression for #7301: removeDaemonTokenFromUrl() strips the fragment
|
|
// for history hygiene, so a refreshed page has no token in the URL at
|
|
// all. The first load must persist the token per-tab and later loads
|
|
// must fall back to it.
|
|
it('survives a page refresh via the per-tab persisted copy', async () => {
|
|
setupToken('', '#token=frag-secret');
|
|
const first = await import('./daemon');
|
|
expect(first.getDaemonToken()).toBe('frag-secret');
|
|
|
|
// Simulate the refresh: fresh module state (in-memory cache gone),
|
|
// URL already cleaned — sessionStorage is all that remains.
|
|
vi.resetModules();
|
|
setupToken('', '#/chat');
|
|
const second = await import('./daemon');
|
|
expect(second.getDaemonToken()).toBe('frag-secret');
|
|
expect(second.getDaemonAuthHeaders()).toEqual({
|
|
Authorization: 'Bearer frag-secret',
|
|
});
|
|
});
|
|
|
|
it('prefers a fresh URL token over a stale persisted one', async () => {
|
|
window.sessionStorage.setItem('qwen-daemon-token', 'stale-secret');
|
|
setupToken('', '#token=new-secret');
|
|
const mod = await import('./daemon');
|
|
expect(mod.getDaemonToken()).toBe('new-secret');
|
|
// The persisted copy is refreshed for the next reload.
|
|
expect(window.sessionStorage.getItem('qwen-daemon-token')).toBe(
|
|
'new-secret',
|
|
);
|
|
});
|
|
|
|
it('degrades gracefully when sessionStorage throws', async () => {
|
|
const original = window.sessionStorage;
|
|
Object.defineProperty(window, 'sessionStorage', {
|
|
get() {
|
|
throw new Error('storage disabled');
|
|
},
|
|
configurable: true,
|
|
});
|
|
try {
|
|
setupToken('', '#token=frag-secret');
|
|
const mod = await import('./daemon');
|
|
// Same-load behavior is unaffected; only refresh persistence is lost.
|
|
expect(mod.getDaemonToken()).toBe('frag-secret');
|
|
} finally {
|
|
Object.defineProperty(window, 'sessionStorage', {
|
|
value: original,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('waitForDaemonTokenMessage', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
function mockFramedWindow() {
|
|
Object.defineProperty(window, 'parent', {
|
|
value: {},
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
it('accepts a bearer token posted from a browser extension parent', async () => {
|
|
mockFramedWindow();
|
|
const mod = await import('./daemon');
|
|
const token = mod.waitForDaemonTokenMessage(1000);
|
|
window.dispatchEvent(
|
|
new MessageEvent('message', {
|
|
data: { type: 'qwen-daemon-auth', token: 'posted-secret' },
|
|
origin: 'chrome-extension://abcdefghijklmnop',
|
|
source: window.parent,
|
|
}),
|
|
);
|
|
await expect(token).resolves.toBe('posted-secret');
|
|
});
|
|
|
|
it('ignores bearer token messages from non-extension origins', async () => {
|
|
mockFramedWindow();
|
|
const mod = await import('./daemon');
|
|
const token = mod.waitForDaemonTokenMessage(1);
|
|
window.dispatchEvent(
|
|
new MessageEvent('message', {
|
|
data: { type: 'qwen-daemon-auth', token: 'evil-secret' },
|
|
origin: 'https://evil.example.com',
|
|
source: window.parent,
|
|
}),
|
|
);
|
|
await expect(token).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('removeDaemonTokenFromUrl', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
// The function is a no-op under import.meta.env.DEV; exercise the
|
|
// production-build path where it actually strips the token.
|
|
vi.stubEnv('DEV', false);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
function setupHref(href: string) {
|
|
const replaceState = vi.fn();
|
|
Object.defineProperty(window, 'location', {
|
|
value: { href },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(window, 'history', {
|
|
value: { replaceState },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
return replaceState;
|
|
}
|
|
|
|
it('strips the token from the fragment', async () => {
|
|
const replaceState = setupHref('http://localhost:4170/#token=secret');
|
|
const mod = await import('./daemon');
|
|
mod.removeDaemonTokenFromUrl();
|
|
expect(replaceState).toHaveBeenCalledTimes(1);
|
|
const next = new URL(String(replaceState.mock.calls[0][2]));
|
|
expect(next.hash).toBe('');
|
|
expect(next.href).not.toContain('token=secret');
|
|
});
|
|
|
|
it('strips the token from the query', async () => {
|
|
const replaceState = setupHref('http://localhost:4170/?token=secret');
|
|
const mod = await import('./daemon');
|
|
mod.removeDaemonTokenFromUrl();
|
|
const next = new URL(String(replaceState.mock.calls[0][2]));
|
|
expect(next.searchParams.has('token')).toBe(false);
|
|
});
|
|
|
|
it('preserves non-token fragment params', async () => {
|
|
const replaceState = setupHref(
|
|
'http://localhost:4170/#token=secret&session=abc',
|
|
);
|
|
const mod = await import('./daemon');
|
|
mod.removeDaemonTokenFromUrl();
|
|
const next = new URL(String(replaceState.mock.calls[0][2]));
|
|
expect(next.hash).toBe('#session=abc');
|
|
expect(next.hash).not.toContain('token');
|
|
});
|
|
|
|
it('is a no-op when no token is present', async () => {
|
|
const replaceState = setupHref('http://localhost:4170/#session=abc');
|
|
const mod = await import('./daemon');
|
|
mod.removeDaemonTokenFromUrl();
|
|
expect(replaceState).not.toHaveBeenCalled();
|
|
});
|
|
});
|