qwen-code/packages/web-shell/client/config/daemon.test.ts
Nothing Chan 459d17cdb5
fix(web-shell): persist the daemon bearer token per-tab so it survives refresh (#7374)
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>
2026-07-21 08:26:19 +00:00

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();
});
});